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

@ -85,6 +85,30 @@ uv run timesheets stories -w --joplin -o stories.md # write to file
uv run timesheets status --joplin
```
## Timesheet table format
Each day's entries live in a markdown pipe table with the following columns:
```markdown
| Start | End | Duration | Project | Story | Note |
|-------|-------|----------|---------|-----------|------------|
| 09:00 | 10:00 | 1:00 | acme | PROJ-123 | Fix bug |
| 10:00 | 10:15 | 0:15 | scrum | | dsu |
```
### Excluding entries from CSV export
Prefix the project name with `~` to mark an entry as **count-but-don't-export**.
The entry is included in `summary` and `status` totals, but omitted from `csv` output.
This is useful for time that is already pre-entered in Odoo (e.g. holidays, days off).
```markdown
| 09:00 | 17:00 | 8:00 | ~Leave | | Public holiday |
```
The `~` is stripped before project name resolution, so `~Leave` maps to the same
project as `Leave` in your `project_map.json`.
## Config file
Copy `timesheets.example.toml` to `timesheets.toml` in the working directory

View file

@ -27,6 +27,7 @@ from .output import (
from .parser import (
aggregate_rows,
filter_rows_by_date,
filter_skip_csv,
filter_week_sections,
parse_document,
resolve_overlaps,
@ -436,7 +437,9 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
day_sections = [
(
_fmt(day),
to_csv_entries(rows) if args.raw else aggregate_rows(rows),
to_csv_entries(rows)
if args.raw
else aggregate_rows(filter_skip_csv(rows)),
)
for day, rows in day_sections_raw
]
@ -450,7 +453,9 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
rows = _resolve_rows(args, config, target_date)
if not rows:
print("Warning: no timesheet rows found in input.", file=sys.stderr)
entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows)
entries = (
to_csv_entries(rows) if args.raw else aggregate_rows(filter_skip_csv(rows))
)
if args.output:
with open(args.output, "w", newline="", encoding="utf-8") as f:
write_csv(entries, f, _fmt(target_date), project_map)

View file

@ -27,7 +27,7 @@ def to_csv_entries(rows: list[dict]) -> list[dict]:
"quantity": row["duration_hours"],
}
for row in rows
if row["duration_hours"] is not None
if row["duration_hours"] is not None and not row.get("skip_csv")
]

View file

@ -103,6 +103,19 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
note = strip_markdown_link(cells[4])
duration = None
project_stripped = project.strip()
if project_stripped.startswith("~"):
skip_csv = True
project = project_stripped[1:].strip()
else:
skip_csv = False
project = project_stripped
note_stripped = note.strip()
if note_stripped.startswith("~"):
skip_csv = True
note = note_stripped[1:].strip()
if start.lower() == "start":
continue
if not re.match(r"^\d+:\d{2}$", start):
@ -112,17 +125,18 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
if not re.match(r"^\d+:\d{2}$", end):
if duration is None:
# No end and no duration — preserve as an open entry
rows.append(
{
"start": start,
"end": None,
"duration_hours": None,
"project": project,
"story": story,
"story_raw": story_raw,
"note": note,
}
)
row = {
"start": start,
"end": None,
"duration_hours": None,
"project": project,
"story": story,
"story_raw": story_raw,
"note": note,
}
if skip_csv:
row["skip_csv"] = True
rows.append(row)
continue
if duration is not None:
@ -135,17 +149,18 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
except ValueError:
continue
rows.append(
{
"start": start,
"end": end,
"duration_hours": duration_hours,
"project": project,
"story": story,
"story_raw": story_raw,
"note": note,
}
)
row = {
"start": start,
"end": end,
"duration_hours": duration_hours,
"project": project,
"story": story,
"story_raw": story_raw,
"note": note,
}
if skip_csv:
row["skip_csv"] = True
rows.append(row)
return rows
@ -354,6 +369,11 @@ def resolve_overlaps(rows: list[dict]) -> list[dict]:
return open_entries + closed
def filter_skip_csv(rows: list[dict]) -> list[dict]:
"""Return a copy of rows with any skip_csv-marked entries removed."""
return [r for r in rows if not r.get("skip_csv")]
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()]

View file

@ -72,6 +72,19 @@ class TestToCsvEntries:
rows = [_raw_row(" bugs ", "", "dsu", 0.25)]
assert to_csv_entries(rows)[0]["project"] == "bugs"
def test_skips_skip_csv_rows(self):
row = {**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True}
assert to_csv_entries([row]) == []
def test_skip_csv_row_mixed_with_normal(self):
rows = [
{**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True},
_raw_row("bugs", "ticket 1", "", 1.0),
]
entries = to_csv_entries(rows)
assert len(entries) == 1
assert entries[0]["project"] == "bugs"
def test_mixed_open_and_closed(self):
rows = [
_raw_row("bugs", "ticket 1", "", 1.0),

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)