streamd/src/timesheet/validation.rs
Konstantin Fickel e0ba2cddf3
feat(timesheet): add overlap detection for timecards
Add find_overlapping_timecards function to detect overlapping time
ranges on the same day. This is used to generate warnings in the
timesheet report.
2026-04-02 16:22:25 +02:00

111 lines
3.6 KiB
Rust

use chrono::NaiveTime;
use crate::models::Timecard;
/// Check if two time ranges overlap.
fn timecards_overlap(a: &Timecard, b: &Timecard) -> bool {
a.from_time < b.to_time && b.from_time < a.to_time
}
/// Find all overlapping timecard pairs for a day.
/// Returns a list of tuples containing the two overlapping timecards.
pub fn find_overlapping_timecards(
timecards: &[Timecard],
) -> Vec<((NaiveTime, NaiveTime), (NaiveTime, NaiveTime))> {
let mut overlaps = Vec::new();
for i in 0..timecards.len() {
for j in (i + 1)..timecards.len() {
if timecards_overlap(&timecards[i], &timecards[j]) {
overlaps.push((
(timecards[i].from_time, timecards[i].to_time),
(timecards[j].from_time, timecards[j].to_time),
));
}
}
}
overlaps
}
#[cfg(test)]
mod tests {
use super::*;
fn time(hour: u32, min: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
}
fn card(from_h: u32, from_m: u32, to_h: u32, to_m: u32) -> Timecard {
Timecard::new(time(from_h, from_m), time(to_h, to_m))
}
#[test]
fn test_no_overlap_adjacent_timecards() {
let timecards = vec![card(9, 0, 12, 0), card(13, 0, 17, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert!(overlaps.is_empty());
}
#[test]
fn test_no_overlap_exact_touch() {
// Touching at 12:00 is NOT an overlap (end time = start time)
let timecards = vec![card(9, 0, 12, 0), card(12, 0, 17, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert!(overlaps.is_empty());
}
#[test]
fn test_partial_overlap() {
let timecards = vec![card(9, 0, 12, 30), card(12, 0, 13, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert_eq!(overlaps.len(), 1);
assert_eq!(overlaps[0].0, (time(9, 0), time(12, 30)));
assert_eq!(overlaps[0].1, (time(12, 0), time(13, 0)));
}
#[test]
fn test_full_containment() {
// One timecard fully contains another
let timecards = vec![card(9, 0, 17, 0), card(10, 0, 11, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert_eq!(overlaps.len(), 1);
}
#[test]
fn test_exact_match_overlap() {
let timecards = vec![card(9, 0, 12, 0), card(9, 0, 12, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert_eq!(overlaps.len(), 1);
}
#[test]
fn test_multiple_overlaps_same_day() {
// First overlaps with second, and second overlaps with third
let timecards = vec![card(9, 0, 11, 0), card(10, 0, 13, 0), card(12, 0, 15, 0)];
let overlaps = find_overlapping_timecards(&timecards);
// 9-11 overlaps with 10-13, and 10-13 overlaps with 12-15
assert_eq!(overlaps.len(), 2);
}
#[test]
fn test_single_timecard_no_overlap() {
let timecards = vec![card(9, 0, 17, 0)];
let overlaps = find_overlapping_timecards(&timecards);
assert!(overlaps.is_empty());
}
#[test]
fn test_empty_timecards_no_overlap() {
let timecards: Vec<Timecard> = vec![];
let overlaps = find_overlapping_timecards(&timecards);
assert!(overlaps.is_empty());
}
#[test]
fn test_three_timecards_all_overlap() {
// All three overlap with each other
let timecards = vec![card(9, 0, 15, 0), card(10, 0, 16, 0), card(11, 0, 17, 0)];
let overlaps = find_overlapping_timecards(&timecards);
// 9-15 with 10-16, 9-15 with 11-17, and 10-16 with 11-17
assert_eq!(overlaps.len(), 3);
}
}