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

@ -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()]