feat(summary): add --weekly/-w and --short/-s flags

--weekly (-w): show the summary for the entire week containing the
given day, fetching from Joplin or parsing all tables in the file

--short (-s, repeatable):
  -s alone:       one line per project label + total
  -s --weekly:    per-day project totals with day subtotals
  -ss --weekly:   one line per day with right-aligned date + week total

Add filter_week_sections() to parser.py to split a document into
(date, rows) pairs for a given ISO week. Add print_summary_short(),
print_summary_weekly(), print_summary_weekly_short(), and
print_summary_weekly_totals() to output.py.
This commit is contained in:
Jef Roosens 2026-05-22 11:39:03 +02:00
parent 6915d8d764
commit ac1e9f959a
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
4 changed files with 304 additions and 23 deletions

View file

@ -4,8 +4,20 @@ import sys
from datetime import date
from .config import find_default_config, get_map_path, get_token, load_config
from .output import print_summary, write_csv
from .parser import aggregate_rows, filter_rows_by_date, parse_document
from .output import (
print_summary,
print_summary_short,
print_summary_weekly,
print_summary_weekly_short,
print_summary_weekly_totals,
write_csv,
)
from .parser import (
aggregate_rows,
filter_rows_by_date,
filter_week_sections,
parse_document,
)
from .projects import load_project_map
from .utils import AmbiguousDateError, format_date, parse_date_arg
@ -85,6 +97,23 @@ def build_parser() -> argparse.ArgumentParser:
help="Print a human-readable summary of time spent per project.",
)
_add_shared_args(summary_parser)
summary_parser.add_argument(
"--weekly",
"-w",
action="store_true",
default=False,
help="Show the summary for the entire week containing the given day.",
)
summary_parser.add_argument(
"--short",
"-s",
action="count",
default=0,
help=(
"Compact output. Once (-s): one line per project with total hours. "
"Twice (-ss): one line per day (only with --weekly)."
),
)
csv_parser = subparsers.add_parser(
"csv",
@ -132,7 +161,7 @@ def _resolve_date(args: argparse.Namespace) -> tuple[date, str]:
def _resolve_rows(
args: argparse.Namespace, config: dict, target_date: date
) -> list[dict]:
"""Fetch and parse rows from the configured source."""
"""Fetch and parse rows from the configured source (single day)."""
if args.joplin:
from .joplin import fetch_week_note
@ -157,6 +186,37 @@ def _resolve_rows(
return parse_document(content.splitlines())
def _resolve_week_sections(
args: argparse.Namespace, config: dict, target_date: date
) -> list[tuple[date, list[dict]]]:
"""Fetch and parse rows from the configured source, grouped by day for the week."""
from datetime import timedelta
week_start = target_date - timedelta(days=target_date.weekday())
if args.joplin:
from .joplin import fetch_week_note
token = _resolve_token(args, config)
try:
content = fetch_week_note(token, target_date)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
return filter_week_sections(content.splitlines(), week_start)
else:
if args.input == "-":
content = sys.stdin.read()
else:
try:
with open(args.input, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(1)
return filter_week_sections(content.splitlines(), week_start)
def _resolve_project_map(args: argparse.Namespace, config: dict) -> dict:
"""Resolve the project map from CLI flag, config, or cwd default."""
map_path = args.map or get_map_path(config)
@ -174,24 +234,42 @@ def _resolve_project_map(args: argparse.Namespace, config: dict) -> dict:
def _cmd_summary(args: argparse.Namespace, config: dict) -> None:
target_date, _ = _resolve_date(args)
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)
project_map = _resolve_project_map(args, config)
short = args.short # 0, 1, or 2
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
# Redirect stdout temporarily so print_summary writes to the file
old_stdout = sys.stdout
sys.stdout = f
try:
print_summary(aggregated, project_map)
finally:
sys.stdout = old_stdout
print(f"Written to {args.output}", file=sys.stderr)
def _write(fn, *fn_args):
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
old_stdout = sys.stdout
sys.stdout = f
try:
fn(*fn_args)
finally:
sys.stdout = old_stdout
print(f"Written to {args.output}", file=sys.stderr)
else:
fn(*fn_args)
if args.weekly:
day_sections = _resolve_week_sections(args, config, target_date)
if not day_sections:
print("Warning: no timesheet rows found for this week.", file=sys.stderr)
return
if short >= 2:
_write(print_summary_weekly_totals, day_sections, project_map)
elif short == 1:
_write(print_summary_weekly_short, day_sections, project_map)
else:
_write(print_summary_weekly, day_sections, project_map)
else:
print_summary(aggregated, project_map)
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)
if short >= 1:
_write(print_summary_short, aggregated, project_map)
else:
_write(print_summary, aggregated, project_map)
def _cmd_csv(args: argparse.Namespace, config: dict) -> None: