541 lines
17 KiB
Rust
541 lines
17 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use itertools::Itertools;
|
|
|
|
use crate::error::StreamdError;
|
|
use crate::models::{LocalizedShard, SpecialDayType, Timecard, Timesheet};
|
|
use crate::query::find_shard_by_set_dimension;
|
|
|
|
use super::configuration::TIMESHEET_DIMENSION_NAME;
|
|
use super::TimesheetPointType;
|
|
|
|
/// A point in time with an associated timesheet type.
|
|
#[derive(Debug, Clone)]
|
|
struct TimesheetPoint {
|
|
moment: DateTime<Utc>,
|
|
point_type: TimesheetPointType,
|
|
}
|
|
|
|
/// Convert a localized shard to a timesheet point.
|
|
fn shard_to_timesheet_point(shard: &LocalizedShard) -> Option<TimesheetPoint> {
|
|
let type_str = shard.location.get(TIMESHEET_DIMENSION_NAME)?;
|
|
let point_type = type_str.parse::<TimesheetPointType>().ok()?;
|
|
|
|
Some(TimesheetPoint {
|
|
moment: shard.moment,
|
|
point_type,
|
|
})
|
|
}
|
|
|
|
/// Convert localized shards to timesheet points.
|
|
fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec<TimesheetPoint> {
|
|
find_shard_by_set_dimension(shards, TIMESHEET_DIMENSION_NAME)
|
|
.iter()
|
|
.filter_map(shard_to_timesheet_point)
|
|
.collect()
|
|
}
|
|
|
|
/// Aggregate timesheet points for a single day into a Timesheet.
|
|
fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>, StreamdError> {
|
|
if points.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let sorted_points: Vec<_> = {
|
|
let mut pts = points.to_vec();
|
|
pts.sort_by_key(|p| p.moment);
|
|
pts
|
|
};
|
|
|
|
let card_date = sorted_points[0].moment.date_naive();
|
|
let mut is_sick_leave = false;
|
|
let mut special_day_type: Option<SpecialDayType> = None;
|
|
|
|
// State machine: starting in "break" mode (not working)
|
|
let mut last_is_break = true;
|
|
let mut last_time = sorted_points[0].moment.time();
|
|
let mut timecards: Vec<Timecard> = Vec::new();
|
|
|
|
for point in &sorted_points {
|
|
if point.moment.date_naive() != card_date {
|
|
return Err(StreamdError::TimesheetError(
|
|
"Dates of all given timesheet days should be consistent".to_string(),
|
|
));
|
|
}
|
|
|
|
let point_time = point.moment.time();
|
|
|
|
match point.point_type {
|
|
TimesheetPointType::Holiday => {
|
|
if special_day_type.is_some() {
|
|
return Err(StreamdError::TimesheetError(format!(
|
|
"{} is both {:?} and {:?}",
|
|
card_date, point.point_type, special_day_type
|
|
)));
|
|
}
|
|
special_day_type = Some(SpecialDayType::Holiday);
|
|
}
|
|
TimesheetPointType::Vacation => {
|
|
if special_day_type.is_some() {
|
|
return Err(StreamdError::TimesheetError(format!(
|
|
"{} is both {:?} and {:?}",
|
|
card_date, point.point_type, special_day_type
|
|
)));
|
|
}
|
|
special_day_type = Some(SpecialDayType::Vacation);
|
|
}
|
|
TimesheetPointType::Undertime => {
|
|
if special_day_type.is_some() {
|
|
return Err(StreamdError::TimesheetError(format!(
|
|
"{} is both {:?} and {:?}",
|
|
card_date, point.point_type, special_day_type
|
|
)));
|
|
}
|
|
special_day_type = Some(SpecialDayType::Undertime);
|
|
}
|
|
TimesheetPointType::SickLeave => {
|
|
is_sick_leave = true;
|
|
}
|
|
TimesheetPointType::Break => {
|
|
if !last_is_break {
|
|
timecards.push(Timecard::new(last_time, point_time));
|
|
last_is_break = true;
|
|
last_time = point_time;
|
|
}
|
|
}
|
|
TimesheetPointType::Card => {
|
|
if last_is_break {
|
|
last_is_break = false;
|
|
last_time = point_time;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check that we ended in break mode
|
|
if !last_is_break {
|
|
return Err(StreamdError::TimesheetError(format!(
|
|
"Last Timecard of {} is not a break!",
|
|
card_date
|
|
)));
|
|
}
|
|
|
|
// Only return a timesheet if there's meaningful data
|
|
if timecards.is_empty() && !is_sick_leave && special_day_type.is_none() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(Timesheet {
|
|
date: card_date,
|
|
is_sick_leave,
|
|
special_day_type,
|
|
timecards,
|
|
}))
|
|
}
|
|
|
|
/// Aggregate timesheet points into timesheets, grouped by day.
|
|
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
|
|
let mut timesheets = Vec::new();
|
|
|
|
// Sort points by moment to ensure proper grouping
|
|
let mut sorted_points = points.to_vec();
|
|
sorted_points.sort_by_key(|p| p.moment);
|
|
|
|
// Group by date
|
|
for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) {
|
|
let day_points: Vec<_> = group.cloned().collect();
|
|
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
|
|
timesheets.push(timesheet);
|
|
}
|
|
}
|
|
|
|
Ok(timesheets)
|
|
}
|
|
|
|
/// Extract timesheets from localized shards.
|
|
pub fn extract_timesheets(shards: &[LocalizedShard]) -> Result<Vec<Timesheet>, StreamdError> {
|
|
let points = shards_to_timesheet_points(shards);
|
|
aggregate_timecards(&points)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::{NaiveTime, TimeZone};
|
|
use indexmap::IndexMap;
|
|
|
|
fn point(at: DateTime<Utc>, point_type: TimesheetPointType) -> LocalizedShard {
|
|
let mut location = IndexMap::new();
|
|
location.insert(
|
|
TIMESHEET_DIMENSION_NAME.to_string(),
|
|
point_type.as_str().to_string(),
|
|
);
|
|
location.insert("file".to_string(), "dummy.md".to_string());
|
|
|
|
LocalizedShard {
|
|
moment: at,
|
|
markers: vec!["Timesheet".to_string()],
|
|
tags: vec![],
|
|
start_line: 1,
|
|
end_line: 1,
|
|
children: vec![],
|
|
location,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_work_block() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(17, 30, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].date, day.date_naive());
|
|
assert!(!result[0].is_sick_leave);
|
|
assert!(result[0].special_day_type.is_none());
|
|
assert_eq!(result[0].timecards.len(), 1);
|
|
assert_eq!(
|
|
result[0].timecards[0].from_time,
|
|
NaiveTime::from_hms_opt(9, 0, 0).unwrap()
|
|
);
|
|
assert_eq!(
|
|
result[0].timecards[0].to_time,
|
|
NaiveTime::from_hms_opt(17, 30, 0).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_three_work_blocks_separated_by_breaks() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].timecards.len(), 3);
|
|
assert_eq!(
|
|
result[0].timecards[0].from_time,
|
|
NaiveTime::from_hms_opt(7, 15, 0).unwrap()
|
|
);
|
|
assert_eq!(
|
|
result[0].timecards[0].to_time,
|
|
NaiveTime::from_hms_opt(12, 0, 0).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_input_order_is_not_required_within_a_day() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].timecards.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_groups_by_day() {
|
|
let day1 = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let day2 = Utc.with_ymd_and_hms(2026, 2, 2, 0, 0, 0).unwrap();
|
|
|
|
let shards = vec![
|
|
point(
|
|
day1.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day1.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day2.with_time(NaiveTime::from_hms_opt(10, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day2.with_time(NaiveTime::from_hms_opt(18, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].date, day1.date_naive());
|
|
assert_eq!(result[1].date, day2.date_naive());
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_only_special_day_type_vacation() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Vacation,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation));
|
|
assert!(result[0].timecards.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_only_special_day_type_holiday() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Holiday,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday));
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_only_special_day_type_undertime() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Undertime,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime));
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_sick_leave_and_timecards() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(7, 30, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::SickLeave,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert!(result[0].is_sick_leave);
|
|
assert_eq!(result[0].timecards.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_sick_leave_only() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::SickLeave,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert!(result[0].is_sick_leave);
|
|
assert!(result[0].timecards.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_input() {
|
|
let result = extract_timesheets(&[]).unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_only_cards_and_no_break_is_invalid() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Card,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards);
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert!(err.to_string().contains("not a break"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_special_day_types_same_day_is_invalid() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Vacation,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(8, 5, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Holiday,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards);
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert!(err.to_string().contains("is both"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_day_with_only_breaks_is_ignored() {
|
|
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
|
|
let shards = vec![
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
point(
|
|
day.with_time(NaiveTime::from_hms_opt(13, 0, 0).unwrap())
|
|
.unwrap(),
|
|
TimesheetPointType::Break,
|
|
),
|
|
];
|
|
|
|
let result = extract_timesheets(&shards).unwrap();
|
|
|
|
assert!(result.is_empty());
|
|
}
|
|
}
|