Add ~ marker to exclude entries from CSV export

Prefix the project name or note column with ~ to mark an entry as
count-but-don't-export. Marked entries are included in summary and
status totals but omitted from all csv output (both --raw and
aggregated, single-day and weekly).

  | 09:00 | 17:00 | 8:00 | ~Leave |  | Day off  |
  | 09:00 | 17:00 | 8:00 |  Leave |  | ~Day off |

The ~ is stripped from whichever field carries it before any
downstream processing, so project map resolution is unaffected.

Implementation:
- parse_table sets skip_csv=True on marked rows and strips the ~
- new filter_skip_csv() helper in parser.py
- to_csv_entries() skips skip_csv rows
- _cmd_csv calls filter_skip_csv() before aggregate_rows()
This commit is contained in:
Jef Roosens 2026-05-28 13:37:44 +02:00
parent 8b6f0b24e2
commit de46399010
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
6 changed files with 209 additions and 25 deletions

View file

@ -8,6 +8,7 @@ from timesheets.parser import (
detect_has_duration_column,
extract_table_blocks,
filter_rows_by_date,
filter_skip_csv,
parse_document,
parse_table,
resolve_overlaps,
@ -351,6 +352,127 @@ class TestBuildDescription:
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# skip_csv marker (~project)
# ---------------------------------------------------------------------------
class TestParseTableSkipCsv:
_LINES = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------|----------|",
"| 09:00 | 17:00 | 8:00 | ~Leave | | Day off |",
"| 09:00 | 10:00 | 1:00 | work | st | |",
]
def test_tilde_sets_skip_csv(self):
rows = parse_table(self._LINES)
leave = next(r for r in rows if "Leave" in r["project"])
assert leave["skip_csv"] is True
def test_tilde_stripped_from_project(self):
rows = parse_table(self._LINES)
leave = next(r for r in rows if r.get("skip_csv"))
assert leave["project"] == "Leave"
def test_tilde_with_space_stripped(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|----------|-------|------|",
"| 09:00 | 17:00 | 8:00 | ~ Leave | | |",
]
rows = parse_table(lines)
assert rows[0]["skip_csv"] is True
assert rows[0]["project"] == "Leave"
def test_normal_row_has_no_skip_csv(self):
rows = parse_table(WITH_DURATION)
assert all("skip_csv" not in r for r in rows)
def test_skip_csv_on_open_entry(self):
lines = [
"| Start | End | Project | Story | Note |",
"|-------|-----|---------|-------|------|",
"| 09:00 | | ~Leave | | |",
]
rows = parse_table(lines, has_duration_col=False)
assert len(rows) == 1
assert rows[0]["skip_csv"] is True
assert rows[0]["project"] == "Leave"
def test_note_tilde_sets_skip_csv(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------|----------|",
"| 09:00 | 17:00 | 8:00 | Leave | | ~Day off |",
]
rows = parse_table(lines)
assert rows[0]["skip_csv"] is True
def test_note_tilde_stripped_from_note(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------|----------|",
"| 09:00 | 17:00 | 8:00 | Leave | | ~Day off |",
]
rows = parse_table(lines)
assert rows[0]["note"] == "Day off"
def test_note_tilde_with_space_stripped(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------|-------|",
"| 09:00 | 17:00 | 8:00 | Leave | | ~ Day off |",
]
rows = parse_table(lines)
assert rows[0]["skip_csv"] is True
assert rows[0]["note"] == "Day off"
def test_note_tilde_without_duration_col(self):
lines = [
"| Start | End | Project | Story | Note |",
"|-------|-------|---------|-------|----------|",
"| 09:00 | 17:00 | Leave | | ~Day off |",
]
rows = parse_table(lines, has_duration_col=False)
assert rows[0]["skip_csv"] is True
assert rows[0]["note"] == "Day off"
def test_note_tilde_does_not_affect_normal_row(self):
rows = parse_table(WITH_DURATION)
assert all("skip_csv" not in r for r in rows)
class TestFilterSkipCsv:
def test_removes_skip_csv_rows(self):
rows = [
{"project": "Leave", "skip_csv": True, "duration_hours": 8.0},
{"project": "work", "duration_hours": 1.0},
]
result = filter_skip_csv(rows)
assert len(result) == 1
assert result[0]["project"] == "work"
def test_keeps_all_normal_rows(self):
rows = [
{"project": "work", "duration_hours": 1.0},
{"project": "scrum", "duration_hours": 0.5},
]
assert filter_skip_csv(rows) == rows
def test_empty_input(self):
assert filter_skip_csv([]) == []
def test_all_skip_csv_returns_empty(self):
rows = [{"project": "Leave", "skip_csv": True, "duration_hours": 8.0}]
assert filter_skip_csv(rows) == []
def test_rows_without_key_treated_as_normal(self):
"""Rows that never had the key at all should pass through."""
rows = [{"project": "work", "duration_hours": 1.0}]
assert filter_skip_csv(rows) == rows
class TestAggregateRows:
def test_same_project_story_summed(self):
rows = parse_table(WITH_DURATION)