setup_env/dotfiles/bin/td
Paul Trowbridge f71458a34f add td time-tracking script and deploy_bin installer
`td` is a Python CLI for tracking time on markdown todos identified
by ^tid block-refs, writing to a CSV at $TD_LOG (default ./time.csv).
Shell wrappers tstart/tstop/treport in .bashrc wrap it.

setup_env.sh gains deploy_bin() which symlinks every file in
dotfiles/bin/ into ~/.local/bin/, mirroring how deploy_configs
handles dotfiles.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:01:35 -04:00

129 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""td — time-track markdown todos identified by ^tid block-refs.
Usage:
td start <tid> [--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()