From f71458a34fd7ee377e4a9127830dc6a8f91b3702 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 20 Apr 2026 21:01:35 -0400 Subject: [PATCH] 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 --- dotfiles/.bashrc | 24 ++++++--- dotfiles/bin/td | 128 +++++++++++++++++++++++++++++++++++++++++++++++ setup_env.sh | 13 +++++ 3 files changed, 158 insertions(+), 7 deletions(-) create mode 100755 dotfiles/bin/td diff --git a/dotfiles/.bashrc b/dotfiles/.bashrc index dc966c1..8eb0403 100644 --- a/dotfiles/.bashrc +++ b/dotfiles/.bashrc @@ -102,21 +102,21 @@ alias opg="lsof 2>/dev/null +D . | grep 'pg.*swp$' | awk '{print \$9}' | sed 's/ alias osw="lsof 2>/dev/null +D . | awk '\$NF ~ /swp$/ {print \$9}' | sed 's/\.swp//g' | sed 's/\/\./\//g'" alias xpg="lsof 2>/dev/null +D . | grep 'pg.*swp$' | awk '{print \$9}' | sed 's/\.swp//g' | sed 's/\/\./\//g' | xargs -r $PG -f" alias xsw="lsof 2>/dev/null +D . | grep '.*swp$' | awk '{print \$9}' | sed 's/\.swp//g' | sed 's/\/\./\//g' | xargs -r $PG -f" -alias ons='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd)"' -alias xns='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | sed "s/\\.swo$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $PG -f %' -alias xnsp='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | sed "s/\\.swo$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $PG -f % | pspg' +alias ons='lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v "lsof:" | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd)"' +alias xns='lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v "lsof:" | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | sed "s/\\.swo$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $PG -f %' +alias xnsp='lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v "lsof:" | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | sed "s/\\.swo$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $PG -f % | pspg' # alias xnspa='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | sed "s/\\.swo$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $PG -f % --csv | vd - > /dev/null 2>&1' #alias xnsp='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd)" | fzf | xargs -I % $PG -f % | pspg' alias mns='fzf | xargs -I {} sqlcmd -U Pricing -S mid-sql02 -C -i {} | pspg' -alias xmsp='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $MS -i % | pspg' -alias xms='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $MSW -i % ' +alias xmsp='lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v "lsof:" | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $MS -i % | pspg' +alias xms='lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v "lsof:" | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $MSW -i % ' # alias xmspa='lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd | sed "s|^//|/|")" | fzf | xargs -I % $MSC -i % | vd -f csv -' # alias xmspa='selected_file=$(lsof +D ~/.local/state/nvim/swap/ | grep -o "/swap/.*" | cut -c 7- | tr "%" "/" | sed "s/\\.swp$//" | grep "$(pwd)" | fzf) && [ -n "$selected_file" ] && $MSC -i "$selected_file" | vd -f csv -' xmspa() { local file file=$( - lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | + lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v 'lsof:' | grep -o "/swap/.*" | sed 's|^/swap/||' | tr "%" "/" | @@ -143,6 +143,13 @@ alias tdtp='rg "\- \[[^(x|~)]\].*⏫"' # alias tdo='rg "\- \[[^x]\]" | fzf | xargs nvim' alias tdo='rg "\- \[[^(x|~)]\]" --line-number | fzf | awk -F: "{print \$1, \"+\"\$2}" | xargs -r nvim' alias tdop='rg "\- \[[^(x|~)]\].*(🔼|⏫)" --line-number | fzf | awk -F: "{print \$1, \"+\"\$2}" | xargs -r nvim' + +# time-track markdown todos — see `command td --help`. Log at $TD_LOG (default ./time.csv) +# `command td` bypasses the `td` alias (which is rg for finding todos). +tstart() { command td start "$@"; } +tstop() { command td stop; } +treport() { command td report "$@"; } + alias gr='git reset HEAD' alias gc='git commit -v' alias gd='git difftool' @@ -155,7 +162,7 @@ xnspa() { local file file=$( - lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | + lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v 'lsof:' | grep -o "/swap/.*" | sed 's|^/swap/||' | tr "%" "/" | @@ -218,3 +225,6 @@ bind 'set bell-style none' [ -f ~/setup_env/dotfiles/.bashrc_local ] && source ~/setup_env/dotfiles/.bashrc_local export PATH=$PATH:~/lua-language-server/bin [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" + +# opencode +export PATH=/home/ptrowbridge/.opencode/bin:$PATH diff --git a/dotfiles/bin/td b/dotfiles/bin/td new file mode 100755 index 0000000..83bd00e --- /dev/null +++ b/dotfiles/bin/td @@ -0,0 +1,128 @@ +#!/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() diff --git a/setup_env.sh b/setup_env.sh index 241d4c8..09a7d32 100755 --- a/setup_env.sh +++ b/setup_env.sh @@ -65,6 +65,18 @@ deploy_configs() { source ~/.bashrc } +# Deploy executable scripts from dotfiles/bin/ into ~/.local/bin/ +deploy_bin() { + echo "Deploying scripts to ~/.local/bin/ ..." + local src_dir="$(pwd)/dotfiles/bin" + [[ -d "$src_dir" ]] || return 0 + mkdir -p ~/.local/bin + for script in "$src_dir"/*; do + [[ -f "$script" ]] || continue + create_symlink "$script" ~/.local/bin/"$(basename "$script")" + done +} + # Main script main() { install_packages @@ -72,6 +84,7 @@ main() { install_vundle install_git_bash_prompt deploy_configs + deploy_bin echo "Setup complete! Please restart your shell or run 'source ~/.bashrc' for changes to take effect." }