feat(status): add status subcommand with day and week metrics

- Add status.py with compute_day_status() and compute_week_status()
- Add projected_hours_for_day(): for each entry, use its duration if
  closed, or the next entry's start time as the close time if open
- Open entries (start present, end absent) are preserved by the parser
  instead of being skipped; aggregate_rows() skips them for summaries
- Expected end is computed by filling remaining hours into available
  time slots from the open entry's start; clamped to latest_end if a
  pre-logged entry ends later, with a note explaining why
- Projected week total sums projected_hours_for_day() across all days
- Add status subcommand to cli.py with shared source/day arguments
- Add [work] daily_hours / weekly_hours config keys (default 8 / 40)
- Add timesheets.example.toml [work] section
- Add tests for projected_hours_for_day, compute_day_status,
  compute_week_status and all DayStatus/WeekStatus fields
This commit is contained in:
Jef Roosens 2026-05-22 13:35:22 +02:00
parent d5dbe8791b
commit f372a691d4
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
8 changed files with 799 additions and 7 deletions

View file

@ -177,12 +177,16 @@ class TestParseTable:
assert parse_table(lines) == []
def test_empty_end_time_row_skipped(self):
# Open entry (no end, no duration) is now preserved with duration_hours=None
lines = [
"| Start | End | Project | Story | Note |",
"|-------|-------|---------|-------|------|",
"|-------|-------|---------|-------|------||",
"| 09:55 | | bugs | | |",
]
assert parse_table(lines, has_duration_col=False) == []
rows = parse_table(lines, has_duration_col=False)
assert len(rows) == 1
assert rows[0]["duration_hours"] is None
assert rows[0]["end"] is None
def test_empty_input(self):
assert parse_table([]) == []
@ -231,15 +235,18 @@ class TestParseDocument:
# File has 5 daily tables; expect a healthy number of rows
assert len(rows) > 20
# All rows must have expected keys
# All closed rows must have positive duration; open entries have None
for row in rows:
assert "project" in row
assert "duration_hours" in row
assert row["duration_hours"] > 0
# The incomplete row (09:55 | empty end) must have been skipped
if row["duration_hours"] is not None:
assert row["duration_hours"] > 0
# The open entry (09:55, no end) must be preserved with duration_hours=None
incomplete = [
r for r in rows if r["start"] == "09:55" and r["project"] == "bugs"
]
assert all(r["duration_hours"] > 0 for r in incomplete)
assert len(incomplete) == 1
assert incomplete[0]["duration_hours"] is None
def test_week_file_no_markdown_links_in_stories(self):
"""Markdown link syntax must be stripped from story/note fields."""