chore: fix timezone handling
Some checks failed
Continuous Integration / Lint, Check & Test (push) Failing after 57s
Release / Build and Release (push) Successful in 5s
Continuous Integration / Build Package (push) Successful in 1m43s

This commit is contained in:
Konstantin Fickel 2026-04-07 13:26:34 +02:00
parent e8dc2013bc
commit 5dca68037d
Signed by: kfickel
GPG key ID: A793722F9933C1A5
7 changed files with 194 additions and 94 deletions

View file

@ -24,7 +24,9 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content); 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); shards.push(shard);
} }
} }

View file

@ -5,6 +5,7 @@ use std::path::Path;
use chrono::Datelike; use chrono::Datelike;
use chrono::NaiveDate; use chrono::NaiveDate;
use chrono::Utc; use chrono::Utc;
use chrono_tz::Tz;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::config::Settings; use crate::config::Settings;
@ -20,7 +21,7 @@ use crate::timesheet::{
DayType, DayWarning, MonthReport, TimesheetReport, DayType, DayWarning, MonthReport, TimesheetReport,
}; };
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> { fn load_all_shards(base_folder: &Path, tz: Tz) -> Result<Vec<LocalizedShard>, StreamdError> {
let mut shards = Vec::new(); let mut shards = Vec::new();
for entry in WalkDir::new(base_folder) for entry in WalkDir::new(base_folder)
@ -34,7 +35,8 @@ fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdErr
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content); 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); shards.push(shard);
} }
} }
@ -321,6 +323,13 @@ pub fn run(decimal: bool, debug: bool) -> Result<(), StreamdError> {
// Load repository configuration // Load repository configuration
let repo_config = load_repository_config(base_folder)?; 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 // Check if timesheet is configured
let timesheet_config = match repo_config.timesheet { let timesheet_config = match repo_config.timesheet {
Some(config) => config, 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 // Load all markdown files and extract timesheets
let all_shards = load_all_shards(base_folder)?; let all_shards = load_all_shards(base_folder, tz)?;
let timesheets = extract_timesheets(&all_shards, Utc::now())?; let timesheets = extract_timesheets(&all_shards, now, tz)?;
// Generate the report // Generate the report
let report = generate_report(&timesheets, &timesheet_config)?; let report = generate_report(&timesheets, &timesheet_config, now, tz)?;
if report.months.is_empty() { if report.months.is_empty() {
println!("No timesheet data found for the configured periods."); println!("No timesheet data found for the configured periods.");

View file

@ -26,7 +26,9 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content); 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); shards.push(shard);
} }
} }

View file

@ -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 once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use std::path::Path; use std::path::Path;
@ -17,15 +18,25 @@ static DATE_MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{8}$").unwr
/// Regex for validating time marker format (6 digits). /// Regex for validating time marker format (6 digits).
static TIME_MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{6}$").unwrap()); static TIME_MARKER_REGEX: Lazy<Regex> = 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<DateTime<Utc>> {
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. /// 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 time component is optional and can be 4-6 digits (HHMM, HHMMS, or HHMMSS).
/// The datetime is interpreted in the given timezone.
/// ///
/// # Examples /// # Examples
/// - "20230101-123456 Some Text.md" -> DateTime for 2023-01-01 12:34:56 /// - "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 /// - "20230101 Some Text.md" -> DateTime for 2023-01-01 00:00:00 in tz
/// - "invalid-file-name.md" -> None /// - "invalid-file-name.md" -> None
pub fn extract_datetime_from_file_name(file_name: &str) -> Option<DateTime<Utc>> { pub fn extract_datetime_from_file_name(file_name: &str, tz: Tz) -> Option<DateTime<Utc>> {
let base_name = Path::new(file_name) let base_name = Path::new(file_name)
.file_name() .file_name()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@ -48,20 +59,23 @@ pub fn extract_datetime_from_file_name(file_name: &str) -> Option<DateTime<Utc>>
NaiveDateTime::parse_from_str(&datetime_str, "%Y%m%d %H:%M:%S") NaiveDateTime::parse_from_str(&datetime_str, "%Y%m%d %H:%M:%S")
.ok() .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<NaiveDateTime> {
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. /// 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. /// Returns the parsed datetime if the format matches and values are valid.
pub fn extract_datetime_from_marker(marker: &str) -> Option<DateTime<Utc>> { pub fn extract_datetime_from_marker(marker: &str, tz: Tz) -> Option<DateTime<Utc>> {
if !DATETIME_MARKER_REGEX.is_match(marker) { parse_naive_datetime_from_marker(marker).and_then(|dt| naive_to_utc(dt, tz))
return None;
}
NaiveDateTime::parse_from_str(marker, "%Y%m%d%H%M%S")
.ok()
.map(|dt| dt.and_utc())
} }
/// Extract a date from a marker string in the exact format: YYYYMMDD. /// 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<NaiveTime> {
/// ///
/// The function processes markers in reverse order, allowing later markers to override /// 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. /// 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: /// Rules:
/// - If a full datetime marker (14 digits) is found, it sets both date and time /// - 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<NaiveTime> {
pub fn extract_datetime_from_marker_list( pub fn extract_datetime_from_marker_list(
markers: &[String], markers: &[String],
inherited_datetime: DateTime<Utc>, inherited_datetime: DateTime<Utc>,
tz: Tz,
) -> DateTime<Utc> { ) -> DateTime<Utc> {
let mut shard_time: Option<NaiveTime> = None; let mut shard_time: Option<NaiveTime> = None;
let mut shard_date: Option<NaiveDate> = None; let mut shard_date: Option<NaiveDate> = None;
@ -111,34 +127,39 @@ pub fn extract_datetime_from_marker_list(
if let Some(date) = extract_date_from_marker(marker) { if let Some(date) = extract_date_from_marker(marker) {
shard_date = Some(date); shard_date = Some(date);
} }
if let Some(datetime) = extract_datetime_from_marker(marker) { if let Some(naive_dt) = parse_naive_datetime_from_marker(marker) {
shard_date = Some(datetime.naive_utc().date()); shard_date = Some(naive_dt.date());
shard_time = Some(datetime.naive_utc().time()); 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 // 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) { let final_time = match (shard_date, shard_time) {
// If we have a date but no time, use midnight // If we have a date but no time, use midnight
(Some(_), None) => NaiveTime::from_hms_opt(0, 0, 0).unwrap(), (Some(_), None) => NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
// Otherwise use the shard time or inherit // 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::TimeZone; use chrono::TimeZone;
use chrono_tz::UTC;
#[test] #[test]
fn test_extract_date_from_file_name_valid() { fn test_extract_date_from_file_name_valid() {
let file_name = "20230101-123456 Some Text.md"; let file_name = "20230101-123456 Some Text.md";
assert_eq!( 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()) Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 56).unwrap())
); );
} }
@ -146,14 +167,14 @@ mod tests {
#[test] #[test]
fn test_extract_date_from_file_name_invalid() { fn test_extract_date_from_file_name_invalid() {
let file_name = "invalid-file-name.md"; 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] #[test]
fn test_extract_date_from_file_name_without_time() { fn test_extract_date_from_file_name_without_time() {
let file_name = "20230101 Some Text.md"; let file_name = "20230101 Some Text.md";
assert_eq!( 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()) 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() { fn test_extract_date_from_file_name_short_time() {
let file_name = "20230101-1234 Some Text.md"; let file_name = "20230101-1234 Some Text.md";
assert_eq!( 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()) Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 0).unwrap())
); );
} }
@ -170,41 +191,61 @@ mod tests {
#[test] #[test]
fn test_extract_date_from_file_name_empty_string() { fn test_extract_date_from_file_name_empty_string() {
let file_name = ""; 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] #[test]
fn test_extract_date_from_file_name_with_full_path() { fn test_extract_date_from_file_name_with_full_path() {
let file_name = "/path/to/20230101-123456 Some Text.md"; let file_name = "/path/to/20230101-123456 Some Text.md";
assert_eq!( 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()) 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] #[test]
fn test_extract_datetime_from_marker_valid() { fn test_extract_datetime_from_marker_valid() {
let marker = "20250101150000"; let marker = "20250101150000";
assert_eq!( 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()) 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] #[test]
fn test_extract_datetime_from_marker_invalid_format() { fn test_extract_datetime_from_marker_invalid_format() {
assert_eq!(extract_datetime_from_marker("2025010115000"), None); // too short assert_eq!(extract_datetime_from_marker("2025010115000", UTC), None); // too short
assert_eq!(extract_datetime_from_marker("202501011500000"), None); // too long assert_eq!(extract_datetime_from_marker("202501011500000", UTC), None); // too long
assert_eq!(extract_datetime_from_marker("2025-01-01T150000"), None); // separators assert_eq!(extract_datetime_from_marker("2025-01-01T150000", UTC), None); // separators
assert_eq!(extract_datetime_from_marker("2025010115000a"), None); // non-digit assert_eq!(extract_datetime_from_marker("2025010115000a", UTC), None); // non-digit
assert_eq!(extract_datetime_from_marker(""), None); assert_eq!(extract_datetime_from_marker("", UTC), None);
} }
#[test] #[test]
fn test_extract_datetime_from_marker_invalid_values() { fn test_extract_datetime_from_marker_invalid_values() {
assert_eq!(extract_datetime_from_marker("20250230120000"), None); // Feb 30 assert_eq!(extract_datetime_from_marker("20250230120000", UTC), None); // Feb 30
assert_eq!(extract_datetime_from_marker("20250101126000"), None); // minute 60 assert_eq!(extract_datetime_from_marker("20250101126000", UTC), None); // minute 60
assert_eq!(extract_datetime_from_marker("20250101240000"), None); // hour 24 assert_eq!(extract_datetime_from_marker("20250101240000", UTC), None); // hour 24
} }
#[test] #[test]
@ -260,7 +301,10 @@ mod tests {
#[test] #[test]
fn test_no_markers_inherits_datetime() { fn test_no_markers_inherits_datetime() {
let inherited = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap(); 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] #[test]
@ -273,7 +317,7 @@ mod tests {
"1234567".to_string(), "1234567".to_string(),
]; ];
assert_eq!( assert_eq!(
extract_datetime_from_marker_list(&markers, inherited), extract_datetime_from_marker_list(&markers, inherited, UTC),
inherited inherited
); );
} }
@ -283,7 +327,7 @@ mod tests {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101".to_string()]; let markers = vec!["20250101".to_string()];
assert_eq!( 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() 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 inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["150000".to_string()]; let markers = vec!["150000".to_string()];
assert_eq!( 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() 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 inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101150000".to_string()]; let markers = vec!["20250101150000".to_string()];
assert_eq!( 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() 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 inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101".to_string(), "150000".to_string()]; let markers = vec!["20250101".to_string(), "150000".to_string()];
assert_eq!( 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() Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
); );
} }
@ -328,7 +372,7 @@ mod tests {
"160000".to_string(), "160000".to_string(),
]; ];
assert_eq!( 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() 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 // The first date (20250101) and first time (150000) should win over the later combined datetime
assert_eq!( 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() Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
); );
} }
@ -358,8 +402,19 @@ mod tests {
"150000".to_string(), // valid "150000".to_string(), // valid
]; ];
assert_eq!( 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() 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()
);
}
} }

View file

@ -1,4 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use indexmap::{IndexMap, IndexSet}; use indexmap::{IndexMap, IndexSet};
use crate::error::StreamdError; use crate::error::StreamdError;
@ -17,12 +18,13 @@ pub fn localize_shard(
config: &RepositoryConfiguration, config: &RepositoryConfiguration,
propagated: &IndexMap<String, String>, propagated: &IndexMap<String, String>,
moment: DateTime<Utc>, moment: DateTime<Utc>,
tz: Tz,
) -> LocalizedShard { ) -> LocalizedShard {
let mut position = propagated.clone(); let mut position = propagated.clone();
let mut private_position: IndexMap<String, String> = IndexMap::new(); let mut private_position: IndexMap<String, String> = IndexMap::new();
// Extract datetime from markers // 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 // Convert markers to a set for if_with checking
let marker_set: IndexSet<String> = shard.markers.iter().cloned().collect(); let marker_set: IndexSet<String> = shard.markers.iter().cloned().collect();
@ -64,7 +66,7 @@ pub fn localize_shard(
let children: Vec<LocalizedShard> = shard let children: Vec<LocalizedShard> = shard
.children .children
.iter() .iter()
.map(|child| localize_shard(child, config, &position, adjusted_moment)) .map(|child| localize_shard(child, config, &position, adjusted_moment, tz))
.collect(); .collect();
// Merge private position into final position // Merge private position into final position
@ -84,11 +86,13 @@ pub fn localize_shard(
/// Localize an entire stream file. /// Localize an entire stream file.
/// ///
/// Extracts the datetime from the file name and localizes the root shard. /// 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( pub fn localize_stream_file(
stream_file: &StreamFile, stream_file: &StreamFile,
config: &RepositoryConfiguration, config: &RepositoryConfiguration,
tz: Tz,
) -> Result<LocalizedShard, StreamdError> { ) -> Result<LocalizedShard, StreamdError> {
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()))?; .ok_or_else(|| StreamdError::DateExtractionError(stream_file.file_name.clone()))?;
let shard = stream_file let shard = stream_file
@ -99,7 +103,13 @@ pub fn localize_stream_file(
let mut initial_location = IndexMap::new(); let mut initial_location = IndexMap::new();
initial_location.insert("file".to_string(), stream_file.file_name.clone()); 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)] #[cfg(test)]
@ -107,6 +117,7 @@ mod tests {
use super::*; use super::*;
use crate::models::{Dimension, Marker, MarkerPlacement}; use crate::models::{Dimension, Marker, MarkerPlacement};
use chrono::TimeZone; use chrono::TimeZone;
use chrono_tz::UTC;
fn make_config() -> RepositoryConfiguration { fn make_config() -> RepositoryConfiguration {
RepositoryConfiguration::new() RepositoryConfiguration::new()
@ -149,7 +160,7 @@ mod tests {
let stream_file = StreamFile::new("20250622-121000 Test File.md") let stream_file = StreamFile::new("20250622-121000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["Streamd".to_string()])); .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!( assert_eq!(
result.moment, result.moment,
@ -170,7 +181,7 @@ mod tests {
Shard::new(1, 1).with_markers(vec!["Timesheet".to_string(), "Streamd".to_string()]), 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!( assert_eq!(
result.moment, result.moment,
@ -202,7 +213,7 @@ mod tests {
let stream_file = StreamFile::new("20260131-210000 Test File.md") 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()])); .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())); 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") 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()])); .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())); 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") 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()])); .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())); 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") 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()])); .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())); assert_eq!(result.location.get("label"), Some(&"a".to_string()));
} }

View file

@ -1,4 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use itertools::Itertools; use itertools::Itertools;
use crate::error::StreamdError; use crate::error::StreamdError;
@ -38,6 +39,7 @@ fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec<TimesheetPoint>
fn aggregate_timecard_day( fn aggregate_timecard_day(
points: &[TimesheetPoint], points: &[TimesheetPoint],
now: DateTime<Utc>, now: DateTime<Utc>,
tz: Tz,
) -> Result<Option<Timesheet>, StreamdError> { ) -> Result<Option<Timesheet>, StreamdError> {
if points.is_empty() { if points.is_empty() {
return Ok(None); return Ok(None);
@ -49,23 +51,23 @@ fn aggregate_timecard_day(
pts 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 is_sick_leave = false;
let mut special_day_type: Option<SpecialDayType> = None; let mut special_day_type: Option<SpecialDayType> = None;
// State machine: starting in "break" mode (not working) // State machine: starting in "break" mode (not working)
let mut last_is_break = true; 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<Timecard> = Vec::new(); let mut timecards: Vec<Timecard> = Vec::new();
for point in &sorted_points { 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( return Err(StreamdError::TimesheetError(
"Dates of all given timesheet days should be consistent".to_string(), "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 { match point.point_type {
TimesheetPointType::Holiday => { TimesheetPointType::Holiday => {
@ -116,9 +118,10 @@ fn aggregate_timecard_day(
// Check that we ended in break mode // Check that we ended in break mode
if !last_is_break { 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 // 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 { } else {
return Err(StreamdError::TimesheetError(format!( return Err(StreamdError::TimesheetError(format!(
"Last Timecard of {} is not a break!", "Last Timecard of {} is not a break!",
@ -144,6 +147,7 @@ fn aggregate_timecard_day(
fn aggregate_timecards( fn aggregate_timecards(
points: &[TimesheetPoint], points: &[TimesheetPoint],
now: DateTime<Utc>, now: DateTime<Utc>,
tz: Tz,
) -> Result<Vec<Timesheet>, StreamdError> { ) -> Result<Vec<Timesheet>, StreamdError> {
let mut timesheets = Vec::new(); let mut timesheets = Vec::new();
@ -151,10 +155,13 @@ fn aggregate_timecards(
let mut sorted_points = points.to_vec(); let mut sorted_points = points.to_vec();
sorted_points.sort_by_key(|p| p.moment); sorted_points.sort_by_key(|p| p.moment);
// Group by date // Group by local date in the configured timezone
for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) { for (_date, group) in &sorted_points
.iter()
.chunk_by(|p| p.moment.with_timezone(&tz).date_naive())
{
let day_points: Vec<_> = group.cloned().collect(); 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); timesheets.push(timesheet);
} }
} }
@ -166,9 +173,10 @@ fn aggregate_timecards(
pub fn extract_timesheets( pub fn extract_timesheets(
shards: &[LocalizedShard], shards: &[LocalizedShard],
now: DateTime<Utc>, now: DateTime<Utc>,
tz: Tz,
) -> Result<Vec<Timesheet>, StreamdError> { ) -> Result<Vec<Timesheet>, StreamdError> {
let points = shards_to_timesheet_points(shards); let points = shards_to_timesheet_points(shards);
aggregate_timecards(&points, now) aggregate_timecards(&points, now, tz)
} }
#[cfg(test)] #[cfg(test)]
@ -177,6 +185,8 @@ mod tests {
use chrono::{NaiveTime, TimeZone}; use chrono::{NaiveTime, TimeZone};
use indexmap::IndexMap; use indexmap::IndexMap;
use chrono_tz::UTC;
/// A fixed "now" in the past, so tests never match today. /// A fixed "now" in the past, so tests never match today.
fn past_now() -> DateTime<Utc> { fn past_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap() 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.len(), 1);
assert_eq!(result[0].date, day.date_naive()); 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.len(), 1);
assert_eq!(result[0].timecards.len(), 3); 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.len(), 1);
assert_eq!(result[0].timecards.len(), 3); 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.len(), 2);
assert_eq!(result[0].date, day1.date_naive()); 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.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation)); 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.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday)); 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.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime)); 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_eq!(result.len(), 1);
assert!(result[0].is_sick_leave); 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_eq!(result.len(), 1);
assert!(result[0].is_sick_leave); assert!(result[0].is_sick_leave);
@ -482,7 +492,7 @@ mod tests {
#[test] #[test]
fn test_empty_input() { fn test_empty_input() {
let result = extract_timesheets(&[], past_now()).unwrap(); let result = extract_timesheets(&[], past_now(), UTC).unwrap();
assert!(result.is_empty()); 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()); assert!(result.is_err());
let err = result.unwrap_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()); assert!(result.is_err());
let err = result.unwrap_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()); assert!(result.is_empty());
} }

View file

@ -2,7 +2,8 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; 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::error::StreamdError;
use crate::models::{SpecialDayType, Timesheet}; use crate::models::{SpecialDayType, Timesheet};
@ -115,6 +116,8 @@ fn calculate_actual_minutes(
pub fn generate_report( pub fn generate_report(
timesheets: &[Timesheet], timesheets: &[Timesheet],
config: &TimesheetConfig, config: &TimesheetConfig,
now: DateTime<Utc>,
tz: Tz,
) -> Result<TimesheetReport, StreamdError> { ) -> Result<TimesheetReport, StreamdError> {
if config.periods.is_empty() { if config.periods.is_empty() {
return Ok(TimesheetReport::new()); 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 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(); let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap();
// Limit to today // Limit to today in the configured timezone
let today = chrono::Local::now().date_naive(); let today = now.with_timezone(&tz).date_naive();
let end_date = latest_period_end.min(today); let end_date = latest_period_end.min(today);
// Group by month and generate reports // Group by month and generate reports
@ -260,7 +263,13 @@ mod tests {
use super::*; use super::*;
use crate::models::Timecard; use crate::models::Timecard;
use crate::timesheet::Period; 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> {
Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap()
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate { fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap() NaiveDate::from_ymd_opt(year, month, day).unwrap()
@ -437,7 +446,7 @@ mod tests {
#[test] #[test]
fn test_generate_report_empty_config() { fn test_generate_report_empty_config() {
let config = TimesheetConfig { periods: vec![] }; 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()); 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 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 config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config, future_now(), UTC).unwrap();
assert_eq!(report.months.len(), 1); assert_eq!(report.months.len(), 1);
assert_eq!(report.months[0].days.len(), 1); assert_eq!(report.months[0].days.len(), 1);
@ -466,7 +475,7 @@ mod tests {
// March 2 is Monday, March 3 is Tuesday // March 2 is Monday, March 3 is Tuesday
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config, future_now(), UTC).unwrap();
assert_eq!(report.months[0].days.len(), 2); 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 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 config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config, future_now(), UTC).unwrap();
// Should only include Mon-Fri (5 days), not Sat-Sun // Should only include Mon-Fri (5 days), not Sat-Sun
let days = &report.months[0].days; 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 config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config, future_now(), UTC).unwrap();
// Should include Saturday // Should include Saturday
let has_saturday = report.months[0] 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 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.has_warnings());
assert!(report.months[0].days[0].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 config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config, future_now(), UTC).unwrap();
// Balance should be +120 min (+2h: 18h actual - 16h expected) // Balance should be +120 min (+2h: 18h actual - 16h expected)
assert_eq!(report.cumulative_balance, 120); assert_eq!(report.cumulative_balance, 120);