diff --git a/src/timesheets/parser.py b/src/timesheets/parser.py index 907dbd8..56d25f0 100644 --- a/src/timesheets/parser.py +++ b/src/timesheets/parser.py @@ -273,6 +273,11 @@ def _minutes_to_time(minutes: int) -> str: return f"{minutes // 60:02d}:{minutes % 60:02d}" +def _round_to_5(minutes: int) -> int: + """Round minutes to the nearest 5-minute boundary.""" + return round(minutes / 5) * 5 + + def _make_closed_row(template: dict, start_m: int, end_m: int) -> dict | None: """ Return a copy of *template* with updated start, end, and duration_hours. @@ -330,7 +335,7 @@ def resolve_overlaps(rows: list[dict]) -> list[dict]: if overlap_end_m <= b_start_m: continue - midpoint_m = (b_start_m + overlap_end_m) // 2 + midpoint_m = _round_to_5((b_start_m + overlap_end_m) // 2) replacements: list[dict] = [] if b_end_m <= a_end_m: diff --git a/tests/test_parser.py b/tests/test_parser.py index a9a1d8e..96bcefd 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -678,3 +678,45 @@ class TestResolveOverlaps: assert result[i]["end"] <= result[i + 1]["start"], ( f"Overlap between entry {i} ({result[i]}) and {i + 1} ({result[i + 1]})" ) + + # --- midpoint rounding to 5 minutes --- + + def test_partial_overlap_midpoint_rounds_down(self): + """Midpoint not on a 5-min boundary is rounded down to the nearest 5 min. + + 09:00-10:00 vs 09:33-10:33 + overlap region: 09:33-10:00 raw midpoint: 09:46 rounded: 09:45 + """ + rows = [self._row("09:00", "10:00", "a"), self._row("09:33", "10:33", "b")] + result = self._sorted(resolve_overlaps(rows)) + assert len(result) == 2 + assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:45") + assert (result[1]["start"], result[1]["end"]) == ("09:45", "10:33") + + def test_partial_overlap_midpoint_rounds_up(self): + """Midpoint not on a 5-min boundary is rounded up to the nearest 5 min. + + 09:00-10:00 vs 09:37-10:37 + overlap region: 09:37-10:00 raw midpoint: 09:48 rounded: 09:50 + """ + rows = [self._row("09:00", "10:00", "a"), self._row("09:37", "10:37", "b")] + result = self._sorted(resolve_overlaps(rows)) + assert len(result) == 2 + assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:50") + assert (result[1]["start"], result[1]["end"]) == ("09:50", "10:37") + + def test_containment_midpoint_rounded(self): + """Containment case: boundary is rounded to the nearest 5 min. + + 09:00-10:00 contains 09:14-09:44 + overlap region: 09:14-09:44 raw midpoint: 09:29 rounded: 09:30 + """ + rows = [self._row("09:00", "10:00", "a"), self._row("09:14", "09:44", "b")] + result = self._sorted(resolve_overlaps(rows)) + assert len(result) == 3 + assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:30") + assert result[0]["project"] == "a" + assert (result[1]["start"], result[1]["end"]) == ("09:30", "09:44") + assert result[1]["project"] == "b" + assert (result[2]["start"], result[2]["end"]) == ("09:44", "10:00") + assert result[2]["project"] == "a"