odoo-timesheets/src/timesheets/cli.py
Jef Roosens f372a691d4
feat(status): add status subcommand with day and week metrics
- Add status.py with compute_day_status() and compute_week_status()
- Add projected_hours_for_day(): for each entry, use its duration if
  closed, or the next entry's start time as the close time if open
- Open entries (start present, end absent) are preserved by the parser
  instead of being skipped; aggregate_rows() skips them for summaries
- Expected end is computed by filling remaining hours into available
  time slots from the open entry's start; clamped to latest_end if a
  pre-logged entry ends later, with a note explaining why
- Projected week total sums projected_hours_for_day() across all days
- Add status subcommand to cli.py with shared source/day arguments
- Add [work] daily_hours / weekly_hours config keys (default 8 / 40)
- Add timesheets.example.toml [work] section
- Add tests for projected_hours_for_day, compute_day_status,
  compute_week_status and all DayStatus/WeekStatus fields
2026-06-02 09:31:08 +02:00

364 lines
12 KiB
Python

import argparse
import os
import sys
from datetime import date
from .config import (
find_default_config,
get_daily_target,
get_map_path,
get_token,
get_weekly_target,
load_config,
)
from .output import (
print_status,
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
# ---------------------------------------------------------------------------
# Shared argument helpers
# ---------------------------------------------------------------------------
def _add_shared_args(parser: argparse.ArgumentParser) -> None:
"""Add arguments common to both subcommands."""
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"--input",
"-i",
help="Path to a markdown file, or '-' to read from stdin.",
default=None,
)
source.add_argument(
"--joplin",
action="store_true",
default=False,
help=(
"Fetch the weekly timesheet note from Joplin. "
"Requires a Joplin API token via --token or JOPLIN_TOKEN."
),
)
parser.add_argument(
"day",
nargs="*",
help=(
"Day to extract timesheets for. Accepts YYYY-MM-DD, MM-DD, DD-MM, "
"or a natural language expression like 'yesterday' or '3 days ago'. "
"Defaults to today."
),
)
parser.add_argument(
"--config",
help="Path to a TOML config file. Defaults to timesheets.toml in the cwd if it exists.",
default=None,
)
parser.add_argument(
"--token",
help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.",
default=None,
)
parser.add_argument(
"--map",
help=(
"Path to a JSON file mapping project keys to Project+Task pairs. "
"Defaults to project_map.json in the cwd if it exists."
),
default=None,
)
parser.add_argument(
"-o",
"--output",
help="Write output to this file instead of stdout.",
default=None,
)
# ---------------------------------------------------------------------------
# Parser construction
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Parse markdown timesheet tables and export them.",
prog="timesheets",
)
subparsers = parser.add_subparsers(dest="command", metavar="command")
subparsers.required = True
summary_parser = subparsers.add_parser(
"summary",
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",
help="Export timesheet entries as CSV.",
)
_add_shared_args(csv_parser)
status_parser = subparsers.add_parser(
"status",
help="Show how many hours remain today and this week.",
)
_add_shared_args(status_parser)
return parser
# ---------------------------------------------------------------------------
# Shared resolution helpers
# ---------------------------------------------------------------------------
def _resolve_token(args: argparse.Namespace, config: dict) -> str:
token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN")
if not token:
print(
"Error: Joplin API token required. "
"Provide --token or set the JOPLIN_TOKEN environment variable.",
file=sys.stderr,
)
sys.exit(1)
return token
def _resolve_date(args: argparse.Namespace) -> tuple[date, str]:
"""Parse the day positional argument and return (date, formatted string)."""
day_input = " ".join(args.day) if args.day else None
if day_input is None:
target_date = date.today()
else:
try:
target_date = parse_date_arg(day_input)
except AmbiguousDateError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
return target_date, format_date(target_date)
def _resolve_rows(
args: argparse.Namespace, config: dict, target_date: date
) -> list[dict]:
"""Fetch and parse rows from the configured source (single day)."""
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)
lines = content.splitlines()
return filter_rows_by_date(lines, target_date)
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 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)
if map_path is None:
default = os.path.join(os.getcwd(), "project_map.json")
if os.path.exists(default):
map_path = default
return load_project_map(map_path)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _cmd_summary(args: argparse.Namespace, config: dict) -> None:
target_date, _ = _resolve_date(args)
project_map = _resolve_project_map(args, config)
short = args.short # 0, 1, or 2
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:
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_status(args: argparse.Namespace, config: dict) -> None:
from datetime import timedelta
from .status import compute_day_status, compute_week_status
target_date, _ = _resolve_date(args)
daily_target = get_daily_target(config)
weekly_target = get_weekly_target(config)
week_start = target_date - timedelta(days=target_date.weekday())
# Fetch the full week document once for both day and week status
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)
lines = content.splitlines()
else:
if args.input == "-":
lines = sys.stdin.read().splitlines()
else:
try:
with open(args.input, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(1)
from .parser import filter_rows_by_date, filter_week_sections
day_rows = filter_rows_by_date(lines, target_date)
day_sections = filter_week_sections(lines, week_start)
day_status = compute_day_status(day_rows, target_date, daily_target)
week_status = compute_week_status(day_sections, target_date, weekly_target)
print_status(day_status, week_status)
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)
aggregated = 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)
print(f"Written to {args.output}", file=sys.stderr)
else:
write_csv(aggregated, sys.stdout, date_str, project_map)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
args = build_parser().parse_args()
config_path = args.config if args.config is not None else find_default_config()
config = load_config(config_path)
if args.command == "summary":
_cmd_summary(args, config)
elif args.command == "csv":
_cmd_csv(args, config)
elif args.command == "status":
_cmd_status(args, config)