#!/usr/bin/env python3 """td — time-track markdown todos identified by ^tid block-refs. Usage: td start [--file PATH] [--desc TEXT] td stop td report [FILTER] # FILTER matches tid or file substring td current # print the currently-running tid (or nothing) td tidgen # print a new unique tid Log location: $TD_LOG (default ./time.csv) """ import argparse, csv, os, re, subprocess, sys from datetime import datetime from collections import defaultdict LOG = os.environ.get("TD_LOG", "./time.csv") FIELDS = ["started_at", "stopped_at", "tid", "file", "description"] def now(): return datetime.now().isoformat(timespec="seconds") def read_rows(): if not os.path.exists(LOG): return [] with open(LOG, newline="") as f: return list(csv.DictReader(f)) def write_rows(rows): with open(LOG, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=FIELDS) w.writeheader() w.writerows(rows) def lookup_task(tid): try: out = subprocess.check_output( ["rg", "--no-heading", "--with-filename", rf"\^{tid}\b", "."], text=True, stderr=subprocess.DEVNULL, ) except (subprocess.CalledProcessError, FileNotFoundError): return "", "" first = out.splitlines()[0] if out else "" if not first: return "", "" path, _, line = first.partition(":") file = path.removeprefix("./") desc = re.sub(r"^\s*-\s*\[.\]\s*", "", line) desc = re.sub(r"\s*\^\S+\s*$", "", desc).strip() return file, desc def cmd_start(args): rows = read_rows() ts = now() stopped = [r["tid"] for r in rows if not r["stopped_at"]] for r in rows: if not r["stopped_at"]: r["stopped_at"] = ts file, desc = args.file or "", args.desc or "" if not (file or desc): file, desc = lookup_task(args.tid) if not file: print(f"warning: ^{args.tid} not found in {os.getcwd()} — logging with empty file/desc", file=sys.stderr) rows.append({"started_at": ts, "stopped_at": "", "tid": args.tid, "file": file, "description": desc}) write_rows(rows) if stopped: print(f" stopped {', '.join(stopped)} first") snippet = f" ({file}: {desc[:50]})" if desc else "" print(f"started {args.tid}{snippet}") def cmd_stop(args): rows = read_rows() if not rows: print(f"no log at {LOG}"); sys.exit(1) ts = now() stopped = [] for r in rows: if not r["stopped_at"]: r["stopped_at"] = ts stopped.append(r["tid"]) if not stopped: print("nothing running"); sys.exit(1) write_rows(rows) print(f"stopped {', '.join(stopped)}") def cmd_report(args): rows = read_rows() if not rows: print(f"no log at {LOG}"); sys.exit(1) totals, files, running = defaultdict(int), {}, [] for r in rows: if args.filter and args.filter not in r["tid"] and args.filter not in r["file"]: continue start = datetime.fromisoformat(r["started_at"]) files[r["tid"]] = r["file"] if r["stopped_at"]: stop = datetime.fromisoformat(r["stopped_at"]) totals[r["tid"]] += int((stop - start).total_seconds()) else: running.append((r["tid"], start, r["file"], r["description"])) for tid in sorted(totals): t = totals[tid] h, m = t // 3600, (t % 3600) // 60 print(f"{tid:<24} {h:>3}h{m:02d}m {files.get(tid,'')}") for tid, start, file, desc in running: print(f"{tid:<24} RUNNING since {start.strftime('%H:%M')} {file}: {desc[:40]}") def cmd_current(args): for r in read_rows(): if not r["stopped_at"]: print(r["tid"]); return def cmd_tidgen(args): print(f"tid-{datetime.now().strftime('%Y%m%d-%H%M%S')}") def main(): p = argparse.ArgumentParser(prog="td", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = p.add_subparsers(dest="cmd", required=True) s = sp.add_parser("start"); s.add_argument("tid"); s.add_argument("--file", default=""); s.add_argument("--desc", default=""); s.set_defaults(func=cmd_start) sp.add_parser("stop").set_defaults(func=cmd_stop) s = sp.add_parser("report"); s.add_argument("filter", nargs="?"); s.set_defaults(func=cmd_report) sp.add_parser("current").set_defaults(func=cmd_current) sp.add_parser("tidgen").set_defaults(func=cmd_tidgen) args = p.parse_args() args.func(args) if __name__ == "__main__": main()