diff --git a/README.md b/README.md index 4f1c929..a4442bd 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ uv run timesheets summary -w -ss --joplin # weekly totals only: one line 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 +uv run timesheets csv -w --joplin # full week +uv run timesheets csv -w --raw --joplin # full week, no aggregation ``` ### stories diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 2364d9c..9dfb378 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -21,6 +21,7 @@ from .output import ( print_summary_weekly_totals, to_csv_entries, write_csv, + write_csv_weekly, ) from .parser import ( aggregate_rows, @@ -140,6 +141,13 @@ def build_parser() -> argparse.ArgumentParser: "combining entries that share the same project and story." ), ) + csv_parser.add_argument( + "--weekly", + "-w", + action="store_true", + default=False, + help="Export the full week containing the given day instead of a single day.", + ) status_parser = subparsers.add_parser( "status", @@ -396,18 +404,37 @@ def _cmd_stories(args: argparse.Namespace, config: dict) -> None: def _cmd_csv(args: argparse.Namespace, config: dict) -> None: target_date, date_str = _resolve_date(args) - 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) project_map = _resolve_project_map(args, config) - if args.output: - with open(args.output, "w", newline="", encoding="utf-8") as f: - write_csv(entries, f, date_str, project_map) - print(f"Written to {args.output}", file=sys.stderr) + if args.weekly: + day_sections_raw = _resolve_week_sections(args, config, target_date) + if not day_sections_raw: + print("Warning: no timesheet rows found for this week.", file=sys.stderr) + return + day_sections = [ + ( + format_date(day), + to_csv_entries(rows) if args.raw else aggregate_rows(rows), + ) + for day, rows in day_sections_raw + ] + if args.output: + with open(args.output, "w", newline="", encoding="utf-8") as f: + write_csv_weekly(day_sections, f, project_map) + print(f"Written to {args.output}", file=sys.stderr) + else: + write_csv_weekly(day_sections, sys.stdout, project_map) else: - write_csv(entries, sys.stdout, date_str, project_map) + 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) + if args.output: + with open(args.output, "w", newline="", encoding="utf-8") as f: + write_csv(entries, f, date_str, project_map) + print(f"Written to {args.output}", file=sys.stderr) + else: + write_csv(entries, sys.stdout, date_str, project_map) # --------------------------------------------------------------------------- diff --git a/src/timesheets/output.py b/src/timesheets/output.py index 925b672..6c70113 100644 --- a/src/timesheets/output.py +++ b/src/timesheets/output.py @@ -31,6 +31,32 @@ def to_csv_entries(rows: list[dict]) -> list[dict]: ] +def write_csv_weekly( + day_sections: list[tuple[str, list[dict]]], + output: IO[str], + project_map: dict, +) -> None: + """Write entries from multiple days as a single CSV with one header row. + + day_sections is a list of (date_str, entries) pairs where entries are + already write_csv-compatible (project, description, quantity). + """ + writer = csv.writer(output) + writer.writerow(["Date*", "Project*", "Task", "Description", "Quantity"]) + for date_str, entries in day_sections: + for entry in entries: + project, task = resolve_project_task(entry["project"], project_map) + writer.writerow( + [ + date_str, + project, + task, + entry["description"], + f"{entry['quantity']:.2f}", + ] + ) + + def write_csv( aggregated: list[dict], output: IO[str], diff --git a/tests/test_output.py b/tests/test_output.py index a315016..b10531c 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,7 +3,13 @@ import io import pytest -from timesheets.output import print_stories, print_summary, to_csv_entries, write_csv +from timesheets.output import ( + print_stories, + print_summary, + to_csv_entries, + write_csv, + write_csv_weekly, +) # --------------------------------------------------------------------------- # Shared fixtures @@ -129,6 +135,69 @@ class TestWriteCsv: assert rows[1][3] == "ticket 1" +# --------------------------------------------------------------------------- +# write_csv_weekly +# --------------------------------------------------------------------------- + + +DAY_SECTIONS = [ + ( + "22/03/26", + [ + {"project": "bugs", "description": "ticket 1", "quantity": 1.0}, + {"project": "scrum", "description": "dsu", "quantity": 0.25}, + ], + ), + ( + "23/03/26", + [ + {"project": "bugs", "description": "ticket 2", "quantity": 0.5}, + ], + ), +] + + +class TestWriteCsvWeekly: + def _run(self, day_sections=None, project_map=None): + buf = io.StringIO() + write_csv_weekly( + DAY_SECTIONS if day_sections is None else day_sections, + buf, + project_map or {}, + ) + buf.seek(0) + return list(csv.reader(buf)) + + def test_header_written_once(self): + rows = self._run() + assert rows[0] == ["Date*", "Project*", "Task", "Description", "Quantity"] + assert sum(1 for r in rows if r[0] == "Date*") == 1 + + def test_row_count(self): + rows = self._run() + assert len(rows) == 1 + 3 # header + 3 entries across 2 days + + def test_correct_date_per_row(self): + rows = self._run() + assert rows[1][0] == "22/03/26" + assert rows[2][0] == "22/03/26" + assert rows[3][0] == "23/03/26" + + def test_empty_sections_produce_header_only(self): + rows = self._run(day_sections=[]) + assert rows == [["Date*", "Project*", "Task", "Description", "Quantity"]] + + def test_project_map_applied(self): + rows = self._run(project_map=PROJECT_MAP) + assert rows[1][1] == "[Factry] Historian" + assert rows[1][2] == "[Historian] Bugs" + + def test_quantity_format(self): + rows = self._run() + assert rows[1][4] == "1.00" + assert rows[3][4] == "0.50" + + # --------------------------------------------------------------------------- # print_summary # ---------------------------------------------------------------------------