feat(parser): resolve overlapping timesheet entries
Parallel work is logged as overlapping entries. resolve_overlaps() splits the shared time equally using the midpoint of the overlap region: - Partial overlap: the midpoint becomes the boundary between the two entries (earlier entry trimmed, later entry delayed). - Full containment: the containing entry is split into two pieces surrounding the contained one, with the midpoint rule applied to the overlap region. Open entries (no end time) are passed through unchanged. resolve_overlaps() is called automatically in filter_rows_by_date, filter_week_sections, and the --input single-day path in cli.py, so all subcommands benefit without further changes.
This commit is contained in:
parent
f99e114770
commit
9f0a6e2027
3 changed files with 302 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ from timesheets.parser import (
|
|||
filter_rows_by_date,
|
||||
parse_document,
|
||||
parse_table,
|
||||
resolve_overlaps,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -370,3 +371,188 @@ class TestAggregateRows:
|
|||
|
||||
def test_empty_input(self):
|
||||
assert aggregate_rows([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_overlaps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveOverlaps:
|
||||
"""Tests for overlap resolution between timesheet entries."""
|
||||
|
||||
def _row(self, start, end, project="proj", story="s", note=""):
|
||||
"""Build a minimal closed row dict."""
|
||||
if end is None:
|
||||
return {
|
||||
"start": start,
|
||||
"end": None,
|
||||
"duration_hours": None,
|
||||
"project": project,
|
||||
"story": story,
|
||||
"story_raw": story,
|
||||
"note": note,
|
||||
}
|
||||
h1, m1 = map(int, start.split(":"))
|
||||
h2, m2 = map(int, end.split(":"))
|
||||
duration = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration_hours": duration,
|
||||
"project": project,
|
||||
"story": story,
|
||||
"story_raw": story,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
def _sorted(self, rows):
|
||||
return sorted(
|
||||
[r for r in rows if r["end"] is not None],
|
||||
key=lambda r: r["start"],
|
||||
)
|
||||
|
||||
# --- no-op cases ---
|
||||
|
||||
def test_empty_input(self):
|
||||
assert resolve_overlaps([]) == []
|
||||
|
||||
def test_single_entry_unchanged(self):
|
||||
rows = [self._row("09:00", "10:00")]
|
||||
assert resolve_overlaps(rows) == rows
|
||||
|
||||
def test_no_overlap_unchanged(self):
|
||||
rows = [self._row("09:00", "10:00"), self._row("10:00", "11:00")]
|
||||
result = resolve_overlaps(rows)
|
||||
assert len(result) == 2
|
||||
s = self._sorted(result)
|
||||
assert (s[0]["start"], s[0]["end"]) == ("09:00", "10:00")
|
||||
assert (s[1]["start"], s[1]["end"]) == ("10:00", "11:00")
|
||||
|
||||
def test_only_open_entries_unchanged(self):
|
||||
rows = [self._row("09:00", None), self._row("10:00", None)]
|
||||
result = resolve_overlaps(rows)
|
||||
assert result == rows
|
||||
|
||||
# --- partial overlap ---
|
||||
|
||||
def test_partial_overlap_spec_example(self):
|
||||
"""Spec example: 9:00-10:00 vs 9:30-10:30 → boundary at 9:45."""
|
||||
rows = [self._row("09:00", "10:00", "a"), self._row("09:30", "10:30", "b")]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
assert len(result) == 2
|
||||
assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:45")
|
||||
assert result[0]["project"] == "a"
|
||||
assert (result[1]["start"], result[1]["end"]) == ("09:45", "10:30")
|
||||
assert result[1]["project"] == "b"
|
||||
|
||||
def test_partial_overlap_duration_recalculated(self):
|
||||
rows = [self._row("09:00", "10:00"), self._row("09:30", "10:30")]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
assert result[0]["duration_hours"] == pytest.approx(0.75) # 45 min
|
||||
assert result[1]["duration_hours"] == pytest.approx(0.75) # 45 min
|
||||
|
||||
def test_partial_overlap_total_hours(self):
|
||||
"""Total logged time after resolution equals the spanned wall-clock time."""
|
||||
rows = [self._row("09:00", "10:00"), self._row("09:30", "10:30")]
|
||||
result = resolve_overlaps(rows)
|
||||
total = sum(r["duration_hours"] for r in result)
|
||||
assert total == pytest.approx(1.5) # 9:00–10:30 = 90 min
|
||||
|
||||
def test_partial_overlap_input_order_independent(self):
|
||||
"""Result should be the same regardless of input order."""
|
||||
rows_forward = [
|
||||
self._row("09:00", "10:00", "a"),
|
||||
self._row("09:30", "10:30", "b"),
|
||||
]
|
||||
rows_reverse = [
|
||||
self._row("09:30", "10:30", "b"),
|
||||
self._row("09:00", "10:00", "a"),
|
||||
]
|
||||
r1 = self._sorted(resolve_overlaps(rows_forward))
|
||||
r2 = self._sorted(resolve_overlaps(rows_reverse))
|
||||
assert [(r["start"], r["end"]) for r in r1] == [
|
||||
(r["start"], r["end"]) for r in r2
|
||||
]
|
||||
|
||||
# --- full containment ---
|
||||
|
||||
def test_containment_spec_example(self):
|
||||
"""Spec example: 9:00-10:00 contains 9:15-9:45 → A1: 9:00-9:30, B: 9:30-9:45, A2: 9:45-10:00."""
|
||||
rows = [self._row("09:00", "10:00", "a"), self._row("09:15", "09:45", "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:45")
|
||||
assert result[1]["project"] == "b"
|
||||
assert (result[2]["start"], result[2]["end"]) == ("09:45", "10:00")
|
||||
assert result[2]["project"] == "a"
|
||||
|
||||
def test_containment_total_hours(self):
|
||||
"""Total after containment resolution equals the outer entry's original duration."""
|
||||
rows = [self._row("09:00", "10:00"), self._row("09:15", "09:45")]
|
||||
result = resolve_overlaps(rows)
|
||||
total = sum(r["duration_hours"] for r in result)
|
||||
assert total == pytest.approx(1.0) # 9:00–10:00 = 60 min
|
||||
|
||||
def test_containment_same_start(self):
|
||||
"""Smaller entry starts at the same time as the larger one."""
|
||||
rows = [self._row("09:00", "10:00", "a"), self._row("09:00", "09:30", "b")]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
assert len(result) == 3
|
||||
assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:15")
|
||||
assert result[0]["project"] == "a"
|
||||
assert (result[1]["start"], result[1]["end"]) == ("09:15", "09:30")
|
||||
assert result[1]["project"] == "b"
|
||||
assert (result[2]["start"], result[2]["end"]) == ("09:30", "10:00")
|
||||
assert result[2]["project"] == "a"
|
||||
|
||||
def test_containment_same_end(self):
|
||||
"""Smaller entry ends at the same time as the larger one."""
|
||||
rows = [self._row("09:00", "10:00", "a"), self._row("09:30", "10:00", "b")]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
assert len(result) == 2
|
||||
assert (result[0]["start"], result[0]["end"]) == ("09:00", "09:45")
|
||||
assert result[0]["project"] == "a"
|
||||
assert (result[1]["start"], result[1]["end"]) == ("09:45", "10:00")
|
||||
assert result[1]["project"] == "b"
|
||||
|
||||
# --- open entries ---
|
||||
|
||||
def test_open_entry_passed_through(self):
|
||||
open_row = self._row("09:00", None, "open")
|
||||
closed_row = self._row("09:30", "10:30", "closed")
|
||||
result = resolve_overlaps([open_row, closed_row])
|
||||
assert any(r["end"] is None for r in result)
|
||||
assert any(r["end"] == "10:30" for r in result)
|
||||
|
||||
# --- metadata preservation ---
|
||||
|
||||
def test_project_and_story_preserved(self):
|
||||
rows = [
|
||||
self._row("09:00", "10:00", project="p1", story="s1", note="n1"),
|
||||
self._row("09:30", "10:30", project="p2", story="s2", note="n2"),
|
||||
]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
assert result[0]["project"] == "p1"
|
||||
assert result[0]["story"] == "s1"
|
||||
assert result[0]["note"] == "n1"
|
||||
assert result[1]["project"] == "p2"
|
||||
assert result[1]["story"] == "s2"
|
||||
assert result[1]["note"] == "n2"
|
||||
|
||||
# --- no remaining overlaps ---
|
||||
|
||||
def test_result_has_no_overlaps(self):
|
||||
"""After resolution, no two entries in the result should overlap."""
|
||||
rows = [
|
||||
self._row("09:00", "11:00", "a"),
|
||||
self._row("09:30", "10:30", "b"),
|
||||
self._row("10:00", "12:00", "c"),
|
||||
]
|
||||
result = self._sorted(resolve_overlaps(rows))
|
||||
for i in range(len(result) - 1):
|
||||
assert result[i]["end"] <= result[i + 1]["start"], (
|
||||
f"Overlap between entry {i} ({result[i]}) and {i + 1} ({result[i + 1]})"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue