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, point_type: TimesheetPointType, } /// Convert a localized shard to a timesheet point. fn shard_to_timesheet_point(shard: &LocalizedShard) -> Option { let type_str = shard.location.get(TIMESHEET_DIMENSION_NAME)?; let point_type = type_str.parse::().ok()?; Some(TimesheetPoint { moment: shard.moment, point_type, }) } /// Convert localized shards to timesheet points. fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec { 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, 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 = 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 = 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, 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, 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, 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()); } }