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
|
|
@ -26,6 +26,7 @@ from .parser import (
|
|||
filter_rows_by_date,
|
||||
filter_week_sections,
|
||||
parse_document,
|
||||
resolve_overlaps,
|
||||
)
|
||||
from .projects import load_project_map
|
||||
from .utils import AmbiguousDateError, format_date, parse_date_arg
|
||||
|
|
@ -211,7 +212,7 @@ def _resolve_rows(
|
|||
except FileNotFoundError:
|
||||
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return parse_document(content.splitlines())
|
||||
return resolve_overlaps(parse_document(content.splitlines()))
|
||||
|
||||
|
||||
def _resolve_week_sections(
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ def filter_rows_by_date(lines: list[str], target: date) -> list[dict]:
|
|||
for section_date, section_lines in sections:
|
||||
if section_date == target_str:
|
||||
rows.extend(parse_document(section_lines))
|
||||
return rows
|
||||
return resolve_overlaps(rows)
|
||||
|
||||
|
||||
def filter_week_sections(
|
||||
|
|
@ -236,12 +236,124 @@ def filter_week_sections(
|
|||
if date_str in week_strs:
|
||||
rows = parse_document(sections[date_str])
|
||||
if rows:
|
||||
result.append((week_strs[date_str], rows))
|
||||
result.append((week_strs[date_str], resolve_overlaps(rows)))
|
||||
|
||||
result.sort(key=lambda x: x[0])
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overlap resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _time_to_minutes(t: str) -> int:
|
||||
"""Convert an HH:MM time string to integer minutes since midnight."""
|
||||
h, m = t.split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
|
||||
|
||||
def _minutes_to_time(minutes: int) -> str:
|
||||
"""Convert integer minutes since midnight to an HH:MM string."""
|
||||
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
||||
|
||||
|
||||
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.
|
||||
Returns None if start_m >= end_m (zero- or negative-duration entry).
|
||||
"""
|
||||
if start_m >= end_m:
|
||||
return None
|
||||
row = dict(template)
|
||||
row["start"] = _minutes_to_time(start_m)
|
||||
row["end"] = _minutes_to_time(end_m)
|
||||
row["duration_hours"] = (end_m - start_m) / 60.0
|
||||
return row
|
||||
|
||||
|
||||
def resolve_overlaps(rows: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Resolve overlapping closed timesheet entries by splitting the overlap equally.
|
||||
|
||||
For a partial overlap, the midpoint of the overlap region becomes the
|
||||
boundary: the earlier entry is trimmed to end at the midpoint, and the
|
||||
later entry is delayed to start at the midpoint.
|
||||
|
||||
For full containment (one entry completely inside another), the containing
|
||||
entry is split into two entries surrounding the contained one, with the
|
||||
midpoint rule applied to the overlap region.
|
||||
|
||||
Open entries (``end`` is ``None``) are passed through unchanged.
|
||||
"""
|
||||
closed = [dict(r) for r in rows if r["end"] is not None]
|
||||
open_entries = [r for r in rows if r["end"] is None]
|
||||
|
||||
if len(closed) <= 1:
|
||||
return rows
|
||||
|
||||
initial_n = len(closed)
|
||||
for _ in range(initial_n * initial_n + 1):
|
||||
closed.sort(key=lambda r: _time_to_minutes(r["start"]))
|
||||
resolved_any = False
|
||||
|
||||
for i in range(len(closed)):
|
||||
a = closed[i]
|
||||
a_start_m = _time_to_minutes(a["start"])
|
||||
a_end_m = _time_to_minutes(a["end"])
|
||||
|
||||
for j in range(i + 1, len(closed)):
|
||||
b = closed[j]
|
||||
b_start_m = _time_to_minutes(b["start"])
|
||||
|
||||
if b_start_m >= a_end_m:
|
||||
break # sorted order: no later b can overlap a
|
||||
|
||||
b_end_m = _time_to_minutes(b["end"])
|
||||
overlap_end_m = min(a_end_m, b_end_m)
|
||||
|
||||
if overlap_end_m <= b_start_m:
|
||||
continue
|
||||
|
||||
midpoint_m = (b_start_m + overlap_end_m) // 2
|
||||
replacements: list[dict] = []
|
||||
|
||||
if b_end_m <= a_end_m:
|
||||
# Full containment: a contains b.
|
||||
# a gets [a_start, midpoint] and [b_end, a_end].
|
||||
# b gets [midpoint, b_end].
|
||||
for entry in (
|
||||
_make_closed_row(a, a_start_m, midpoint_m),
|
||||
_make_closed_row(b, midpoint_m, b_end_m),
|
||||
_make_closed_row(a, b_end_m, a_end_m),
|
||||
):
|
||||
if entry is not None:
|
||||
replacements.append(entry)
|
||||
else:
|
||||
# Partial overlap: a starts first, b ends after a.
|
||||
# a gets [a_start, midpoint], b gets [midpoint, b_end].
|
||||
for entry in (
|
||||
_make_closed_row(a, a_start_m, midpoint_m),
|
||||
_make_closed_row(b, midpoint_m, b_end_m),
|
||||
):
|
||||
if entry is not None:
|
||||
replacements.append(entry)
|
||||
|
||||
closed = [closed[k] for k in range(len(closed)) if k != i and k != j]
|
||||
closed.extend(replacements)
|
||||
resolved_any = True
|
||||
break
|
||||
|
||||
if resolved_any:
|
||||
break
|
||||
|
||||
if not resolved_any:
|
||||
break
|
||||
|
||||
closed.sort(key=lambda r: _time_to_minutes(r["start"]))
|
||||
return open_entries + closed
|
||||
|
||||
|
||||
def build_description(story: str, note: str) -> str:
|
||||
"""Combine story and note into a single description string."""
|
||||
parts = [p.strip() for p in [story, note] if p.strip()]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue