Compare commits
No commits in common. "5dca68037def5977d323b203d1d60d09060aeb77" and "2ab2b6a52b890dec43029f6f70a936a061a17c86" have entirely different histories.
5dca68037d
...
2ab2b6a52b
7 changed files with 99 additions and 219 deletions
|
|
@ -24,9 +24,7 @@ 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) =
|
if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) {
|
||||||
localize_stream_file(&stream_file, &TaskConfiguration, chrono_tz::UTC)
|
|
||||||
{
|
|
||||||
shards.push(shard);
|
shards.push(shard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ use std::path::Path;
|
||||||
|
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use chrono::Utc;
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::config::Settings;
|
use crate::config::Settings;
|
||||||
|
|
@ -21,7 +19,7 @@ use crate::timesheet::{
|
||||||
DayType, DayWarning, MonthReport, TimesheetReport,
|
DayType, DayWarning, MonthReport, TimesheetReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn load_all_shards(base_folder: &Path, tz: Tz) -> Result<Vec<LocalizedShard>, StreamdError> {
|
fn load_all_shards(base_folder: &Path) -> 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)
|
||||||
|
|
@ -35,8 +33,7 @@ fn load_all_shards(base_folder: &Path, tz: Tz) -> Result<Vec<LocalizedShard>, St
|
||||||
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, tz)
|
if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration) {
|
||||||
{
|
|
||||||
shards.push(shard);
|
shards.push(shard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,13 +320,6 @@ 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,
|
||||||
|
|
@ -347,14 +337,12 @@ 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, tz)?;
|
let all_shards = load_all_shards(base_folder)?;
|
||||||
let timesheets = extract_timesheets(&all_shards, now, tz)?;
|
let timesheets = extract_timesheets(&all_shards)?;
|
||||||
|
|
||||||
// Generate the report
|
// Generate the report
|
||||||
let report = generate_report(×heets, ×heet_config, now, tz)?;
|
let report = generate_report(×heets, ×heet_config)?;
|
||||||
|
|
||||||
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.");
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@ 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) =
|
if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) {
|
||||||
localize_stream_file(&stream_file, &TaskConfiguration, chrono_tz::UTC)
|
|
||||||
{
|
|
||||||
shards.push(shard);
|
shards.push(shard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, 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;
|
||||||
|
|
@ -18,25 +17,15 @@ 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 in tz
|
/// - "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 in tz
|
/// - "20230101 Some Text.md" -> DateTime for 2023-01-01 00:00:00
|
||||||
/// - "invalid-file-name.md" -> None
|
/// - "invalid-file-name.md" -> None
|
||||||
pub fn extract_datetime_from_file_name(file_name: &str, tz: Tz) -> Option<DateTime<Utc>> {
|
pub fn extract_datetime_from_file_name(file_name: &str) -> 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())
|
||||||
|
|
@ -59,23 +48,20 @@ pub fn extract_datetime_from_file_name(file_name: &str, tz: Tz) -> Option<DateTi
|
||||||
|
|
||||||
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()
|
||||||
.and_then(|dt| naive_to_utc(dt, tz))
|
.map(|dt| dt.and_utc())
|
||||||
}
|
|
||||||
|
|
||||||
/// 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, tz: Tz) -> Option<DateTime<Utc>> {
|
pub fn extract_datetime_from_marker(marker: &str) -> Option<DateTime<Utc>> {
|
||||||
parse_naive_datetime_from_marker(marker).and_then(|dt| naive_to_utc(dt, tz))
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a date from a marker string in the exact format: YYYYMMDD.
|
/// Extract a date from a marker string in the exact format: YYYYMMDD.
|
||||||
|
|
@ -104,7 +90,6 @@ 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
|
||||||
|
|
@ -114,7 +99,6 @@ 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;
|
||||||
|
|
@ -127,39 +111,34 @@ 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(naive_dt) = parse_naive_datetime_from_marker(marker) {
|
if let Some(datetime) = extract_datetime_from_marker(marker) {
|
||||||
shard_date = Some(naive_dt.date());
|
shard_date = Some(datetime.naive_utc().date());
|
||||||
shard_time = Some(naive_dt.time());
|
shard_time = Some(datetime.naive_utc().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_local.date());
|
let final_date = shard_date.unwrap_or_else(|| inherited_datetime.naive_utc().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_local.time()),
|
_ => shard_time.unwrap_or_else(|| inherited_datetime.naive_utc().time()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let naive = NaiveDateTime::new(final_date, final_time);
|
NaiveDateTime::new(final_date, final_time).and_utc()
|
||||||
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, UTC),
|
extract_datetime_from_file_name(file_name),
|
||||||
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())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +146,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, UTC), None);
|
assert_eq!(extract_datetime_from_file_name(file_name), 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, UTC),
|
extract_datetime_from_file_name(file_name),
|
||||||
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())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +162,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, UTC),
|
extract_datetime_from_file_name(file_name),
|
||||||
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())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -191,61 +170,41 @@ 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, UTC), None);
|
assert_eq!(extract_datetime_from_file_name(file_name), 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, UTC),
|
extract_datetime_from_file_name(file_name),
|
||||||
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, UTC),
|
extract_datetime_from_marker(marker),
|
||||||
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", UTC), None); // too short
|
assert_eq!(extract_datetime_from_marker("2025010115000"), None); // too short
|
||||||
assert_eq!(extract_datetime_from_marker("202501011500000", UTC), None); // too long
|
assert_eq!(extract_datetime_from_marker("202501011500000"), None); // too long
|
||||||
assert_eq!(extract_datetime_from_marker("2025-01-01T150000", UTC), None); // separators
|
assert_eq!(extract_datetime_from_marker("2025-01-01T150000"), None); // separators
|
||||||
assert_eq!(extract_datetime_from_marker("2025010115000a", UTC), None); // non-digit
|
assert_eq!(extract_datetime_from_marker("2025010115000a"), None); // non-digit
|
||||||
assert_eq!(extract_datetime_from_marker("", UTC), None);
|
assert_eq!(extract_datetime_from_marker(""), 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", UTC), None); // Feb 30
|
assert_eq!(extract_datetime_from_marker("20250230120000"), None); // Feb 30
|
||||||
assert_eq!(extract_datetime_from_marker("20250101126000", UTC), None); // minute 60
|
assert_eq!(extract_datetime_from_marker("20250101126000"), None); // minute 60
|
||||||
assert_eq!(extract_datetime_from_marker("20250101240000", UTC), None); // hour 24
|
assert_eq!(extract_datetime_from_marker("20250101240000"), None); // hour 24
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -301,10 +260,7 @@ 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!(
|
assert_eq!(extract_datetime_from_marker_list(&[], inherited), inherited);
|
||||||
extract_datetime_from_marker_list(&[], inherited, UTC),
|
|
||||||
inherited
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -317,7 +273,7 @@ mod tests {
|
||||||
"1234567".to_string(),
|
"1234567".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_datetime_from_marker_list(&markers, inherited, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
inherited
|
inherited
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +283,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, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +293,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, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 6, 7, 15, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 6, 7, 15, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +303,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, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +313,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, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +328,7 @@ mod tests {
|
||||||
"160000".to_string(),
|
"160000".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_datetime_from_marker_list(&markers, inherited, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -387,7 +343,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, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -402,19 +358,8 @@ mod tests {
|
||||||
"150000".to_string(), // valid
|
"150000".to_string(), // valid
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_datetime_from_marker_list(&markers, inherited, UTC),
|
extract_datetime_from_marker_list(&markers, inherited),
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
@ -18,13 +17,12 @@ 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, tz);
|
let adjusted_moment = extract_datetime_from_marker_list(&shard.markers, moment);
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
@ -66,7 +64,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, tz))
|
.map(|child| localize_shard(child, config, &position, adjusted_moment))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Merge private position into final position
|
// Merge private position into final position
|
||||||
|
|
@ -86,13 +84,11 @@ 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, tz)
|
let shard_date = extract_datetime_from_file_name(&stream_file.file_name)
|
||||||
.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
|
||||||
|
|
@ -103,13 +99,7 @@ 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(
|
Ok(localize_shard(shard, config, &initial_location, shard_date))
|
||||||
shard,
|
|
||||||
config,
|
|
||||||
&initial_location,
|
|
||||||
shard_date,
|
|
||||||
tz,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -117,7 +107,6 @@ 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()
|
||||||
|
|
@ -160,7 +149,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.moment,
|
result.moment,
|
||||||
|
|
@ -181,7 +170,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.moment,
|
result.moment,
|
||||||
|
|
@ -213,7 +202,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.location.get("project"), Some(&"b".to_string()));
|
assert_eq!(result.location.get("project"), Some(&"b".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +226,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.location.get("project"), Some(&"a".to_string()));
|
assert_eq!(result.location.get("project"), Some(&"a".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +250,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.location.get("label"), Some(&"b".to_string()));
|
assert_eq!(result.location.get("label"), Some(&"b".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +275,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, UTC).unwrap();
|
let result = localize_stream_file(&stream_file, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.location.get("label"), Some(&"a".to_string()));
|
assert_eq!(result.location.get("label"), Some(&"a".to_string()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
@ -36,11 +35,7 @@ fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec<TimesheetPoint>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregate timesheet points for a single day into a Timesheet.
|
/// Aggregate timesheet points for a single day into a Timesheet.
|
||||||
fn aggregate_timecard_day(
|
fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>, StreamdError> {
|
||||||
points: &[TimesheetPoint],
|
|
||||||
now: DateTime<Utc>,
|
|
||||||
tz: Tz,
|
|
||||||
) -> Result<Option<Timesheet>, StreamdError> {
|
|
||||||
if points.is_empty() {
|
if points.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -51,23 +46,23 @@ fn aggregate_timecard_day(
|
||||||
pts
|
pts
|
||||||
};
|
};
|
||||||
|
|
||||||
let card_date = sorted_points[0].moment.with_timezone(&tz).date_naive();
|
let card_date = sorted_points[0].moment.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.with_timezone(&tz).time();
|
let mut last_time = sorted_points[0].moment.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.with_timezone(&tz).date_naive() != card_date {
|
if point.moment.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.with_timezone(&tz).time();
|
let point_time = point.moment.time();
|
||||||
|
|
||||||
match point.point_type {
|
match point.point_type {
|
||||||
TimesheetPointType::Holiday => {
|
TimesheetPointType::Holiday => {
|
||||||
|
|
@ -118,17 +113,11 @@ 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 {
|
||||||
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_local.time()));
|
|
||||||
} 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!",
|
||||||
card_date
|
card_date
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Only return a timesheet if there's meaningful data
|
// Only return a timesheet if there's meaningful data
|
||||||
if timecards.is_empty() && !is_sick_leave && special_day_type.is_none() {
|
if timecards.is_empty() && !is_sick_leave && special_day_type.is_none() {
|
||||||
|
|
@ -144,24 +133,17 @@ fn aggregate_timecard_day(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregate timesheet points into timesheets, grouped by day.
|
/// Aggregate timesheet points into timesheets, grouped by day.
|
||||||
fn aggregate_timecards(
|
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
|
||||||
points: &[TimesheetPoint],
|
|
||||||
now: DateTime<Utc>,
|
|
||||||
tz: Tz,
|
|
||||||
) -> Result<Vec<Timesheet>, StreamdError> {
|
|
||||||
let mut timesheets = Vec::new();
|
let mut timesheets = Vec::new();
|
||||||
|
|
||||||
// Sort points by moment to ensure proper grouping
|
// Sort points by moment to ensure proper grouping
|
||||||
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 local date in the configured timezone
|
// Group by date
|
||||||
for (_date, group) in &sorted_points
|
for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) {
|
||||||
.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, tz)? {
|
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
|
||||||
timesheets.push(timesheet);
|
timesheets.push(timesheet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,13 +152,9 @@ fn aggregate_timecards(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract timesheets from localized shards.
|
/// Extract timesheets from localized shards.
|
||||||
pub fn extract_timesheets(
|
pub fn extract_timesheets(shards: &[LocalizedShard]) -> Result<Vec<Timesheet>, StreamdError> {
|
||||||
shards: &[LocalizedShard],
|
|
||||||
now: DateTime<Utc>,
|
|
||||||
tz: Tz,
|
|
||||||
) -> Result<Vec<Timesheet>, StreamdError> {
|
|
||||||
let points = shards_to_timesheet_points(shards);
|
let points = shards_to_timesheet_points(shards);
|
||||||
aggregate_timecards(&points, now, tz)
|
aggregate_timecards(&points)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -185,13 +163,6 @@ 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.
|
|
||||||
fn past_now() -> DateTime<Utc> {
|
|
||||||
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn point(at: DateTime<Utc>, point_type: TimesheetPointType) -> LocalizedShard {
|
fn point(at: DateTime<Utc>, point_type: TimesheetPointType) -> LocalizedShard {
|
||||||
let mut location = IndexMap::new();
|
let mut location = IndexMap::new();
|
||||||
location.insert(
|
location.insert(
|
||||||
|
|
@ -227,7 +198,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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());
|
||||||
|
|
@ -280,7 +251,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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);
|
||||||
|
|
@ -331,7 +302,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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);
|
||||||
|
|
@ -365,7 +336,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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());
|
||||||
|
|
@ -388,7 +359,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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));
|
||||||
|
|
@ -411,7 +382,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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));
|
||||||
|
|
@ -433,7 +404,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).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));
|
||||||
|
|
@ -460,7 +431,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert!(result[0].is_sick_leave);
|
assert!(result[0].is_sick_leave);
|
||||||
|
|
@ -483,7 +454,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert!(result[0].is_sick_leave);
|
assert!(result[0].is_sick_leave);
|
||||||
|
|
@ -492,7 +463,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_input() {
|
fn test_empty_input() {
|
||||||
let result = extract_timesheets(&[], past_now(), UTC).unwrap();
|
let result = extract_timesheets(&[]).unwrap();
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,7 +483,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC);
|
let result = extract_timesheets(&shards);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
|
|
@ -540,7 +511,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC);
|
let result = extract_timesheets(&shards);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
|
|
@ -563,7 +534,7 @@ mod tests {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = extract_timesheets(&shards, past_now(), UTC).unwrap();
|
let result = extract_timesheets(&shards).unwrap();
|
||||||
|
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use chrono_tz::Tz;
|
|
||||||
|
|
||||||
use crate::error::StreamdError;
|
use crate::error::StreamdError;
|
||||||
use crate::models::{SpecialDayType, Timesheet};
|
use crate::models::{SpecialDayType, Timesheet};
|
||||||
|
|
@ -116,8 +115,6 @@ 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());
|
||||||
|
|
@ -131,8 +128,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 in the configured timezone
|
// Limit to today
|
||||||
let today = now.with_timezone(&tz).date_naive();
|
let today = chrono::Local::now().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
|
||||||
|
|
@ -263,13 +260,7 @@ 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, TimeZone};
|
use chrono::NaiveTime;
|
||||||
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()
|
||||||
|
|
@ -446,7 +437,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, future_now(), UTC).unwrap();
|
let report = generate_report(&[], &config).unwrap();
|
||||||
assert!(report.months.is_empty());
|
assert!(report.months.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -456,7 +447,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(×heets, &config, future_now(), UTC).unwrap();
|
let report = generate_report(×heets, &config).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);
|
||||||
|
|
@ -475,7 +466,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(×heets, &config, future_now(), UTC).unwrap();
|
let report = generate_report(×heets, &config).unwrap();
|
||||||
|
|
||||||
assert_eq!(report.months[0].days.len(), 2);
|
assert_eq!(report.months[0].days.len(), 2);
|
||||||
|
|
||||||
|
|
@ -493,7 +484,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(×heets, &config, future_now(), UTC).unwrap();
|
let report = generate_report(×heets, &config).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;
|
||||||
|
|
@ -512,7 +503,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(×heets, &config, future_now(), UTC).unwrap();
|
let report = generate_report(×heets, &config).unwrap();
|
||||||
|
|
||||||
// Should include Saturday
|
// Should include Saturday
|
||||||
let has_saturday = report.months[0]
|
let has_saturday = report.months[0]
|
||||||
|
|
@ -535,7 +526,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, future_now(), UTC).unwrap();
|
let report = generate_report(&[ts], &config).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());
|
||||||
|
|
@ -550,7 +541,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(×heets, &config, future_now(), UTC).unwrap();
|
let report = generate_report(×heets, &config).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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue