diff --git a/.coverage b/.coverage deleted file mode 100644 index 7f46424..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index efc3959..7ce0ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ wheels/ timesheets.toml timesheets.csv + +# Test coverage +.coverage +htmlcov/ diff --git a/README.md b/README.md index a8bfb04..4f1c929 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ uv run timesheets summary -w -ss --joplin # weekly totals only: one line ```sh uv run timesheets csv --joplin # stdout uv run timesheets csv --joplin -o output.csv # write to file +uv run timesheets csv --raw --joplin # one row per entry, no aggregation ``` ### stories diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index b44290e..2364d9c 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -19,6 +19,7 @@ from .output import ( print_summary_weekly, print_summary_weekly_short, print_summary_weekly_totals, + to_csv_entries, write_csv, ) from .parser import ( @@ -130,6 +131,15 @@ def build_parser() -> argparse.ArgumentParser: help="Export timesheet entries as CSV.", ) _add_shared_args(csv_parser) + csv_parser.add_argument( + "--raw", + action="store_true", + default=False, + help=( + "Skip aggregation: output one CSV row per timesheet entry instead of " + "combining entries that share the same project and story." + ), + ) status_parser = subparsers.add_parser( "status", @@ -389,15 +399,15 @@ 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) - aggregated = aggregate_rows(rows) + entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows) project_map = _resolve_project_map(args, config) if args.output: with open(args.output, "w", newline="", encoding="utf-8") as f: - write_csv(aggregated, f, date_str, project_map) + write_csv(entries, f, date_str, project_map) print(f"Written to {args.output}", file=sys.stderr) else: - write_csv(aggregated, sys.stdout, date_str, project_map) + write_csv(entries, sys.stdout, date_str, project_map) # --------------------------------------------------------------------------- diff --git a/src/timesheets/output.py b/src/timesheets/output.py index c1bbf98..925b672 100644 --- a/src/timesheets/output.py +++ b/src/timesheets/output.py @@ -11,6 +11,26 @@ from .projects import resolve_project_task from .utils import decimal_to_hhmm +def to_csv_entries(rows: list[dict]) -> list[dict]: + """Convert raw parsed rows to write_csv-compatible entries without aggregating. + + Each row becomes its own entry. Open entries (duration_hours is None) are + skipped. Rows are not combined, even if they share the same project and + description. + """ + from .parser import build_description + + return [ + { + "project": row["project"].strip(), + "description": build_description(row["story"], row["note"]), + "quantity": row["duration_hours"], + } + for row in rows + if row["duration_hours"] is not None + ] + + def write_csv( aggregated: list[dict], output: IO[str], diff --git a/tests/test_output.py b/tests/test_output.py index ecda4b9..a315016 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,7 +3,7 @@ import io import pytest -from timesheets.output import print_stories, print_summary, write_csv +from timesheets.output import print_stories, print_summary, to_csv_entries, write_csv # --------------------------------------------------------------------------- # Shared fixtures @@ -20,6 +20,64 @@ AGGREGATED = [ ] +# --------------------------------------------------------------------------- +# to_csv_entries +# --------------------------------------------------------------------------- + + +def _raw_row(project, story, note, duration_hours): + return { + "project": project, + "story": story, + "story_raw": story, + "note": note, + "start": "09:00", + "end": "10:00" if duration_hours is not None else None, + "duration_hours": duration_hours, + } + + +class TestToCsvEntries: + def test_basic_conversion(self): + rows = [_raw_row("bugs", "ticket 1", "", 1.0)] + assert to_csv_entries(rows) == [ + {"project": "bugs", "description": "ticket 1", "quantity": 1.0} + ] + + def test_skips_open_entries(self): + rows = [_raw_row("bugs", "ticket 1", "", None)] + assert to_csv_entries(rows) == [] + + def test_does_not_aggregate(self): + rows = [ + _raw_row("bugs", "ticket 1", "", 0.5), + _raw_row("bugs", "ticket 1", "", 0.5), + ] + entries = to_csv_entries(rows) + assert len(entries) == 2 + assert entries[0]["quantity"] == 0.5 + assert entries[1]["quantity"] == 0.5 + + def test_description_combines_story_and_note(self): + rows = [_raw_row("bugs", "ticket 1", "fix", 1.0)] + assert to_csv_entries(rows)[0]["description"] == "ticket 1 - fix" + + def test_project_stripped(self): + rows = [_raw_row(" bugs ", "", "dsu", 0.25)] + assert to_csv_entries(rows)[0]["project"] == "bugs" + + def test_mixed_open_and_closed(self): + rows = [ + _raw_row("bugs", "ticket 1", "", 1.0), + _raw_row("bugs", "ticket 2", "", None), + _raw_row("scrum", "", "dsu", 0.25), + ] + entries = to_csv_entries(rows) + assert len(entries) == 2 + assert entries[0]["description"] == "ticket 1" + assert entries[1]["description"] == "dsu" + + # --------------------------------------------------------------------------- # write_csv # ---------------------------------------------------------------------------