From fcf1132a6168b04bbb2a098db259b727c04f09aa Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 24 Apr 2026 17:25:06 -0400 Subject: [PATCH] add td week subcommand, nvim centralization, and README td week parses git log for task create/complete events and joins with time.csv for weekly "what got done" reports. Nvim integration moved into dotfiles/nvim/ (symlinked in by new deploy_nvim step) so all three td surfaces live under dotfiles. README covers the deploy pattern and td. Co-Authored-By: Claude Opus 4.7 --- dotfiles/.bashrc | 1 + dotfiles/README.md | 94 +++++++++++++++++++++++++++ dotfiles/bin/td | 102 ++++++++++++++++++++++++++++- dotfiles/nvim/td.lua | 119 ++++++++++++++++++++++++++++++++++ dotfiles/nvim/td_mappings.lua | 11 ++++ setup_env.sh | 18 +++++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 dotfiles/README.md create mode 100644 dotfiles/nvim/td.lua create mode 100644 dotfiles/nvim/td_mappings.lua diff --git a/dotfiles/.bashrc b/dotfiles/.bashrc index 8eb0403..a9c61e5 100644 --- a/dotfiles/.bashrc +++ b/dotfiles/.bashrc @@ -149,6 +149,7 @@ alias tdop='rg "\- \[[^(x|~)]\].*(🔼|⏫)" --line-number | fzf | awk -F: "{pri tstart() { command td start "$@"; } tstop() { command td stop; } treport() { command td report "$@"; } +tweek() { command td week "$@"; } alias gr='git reset HEAD' alias gc='git commit -v' diff --git a/dotfiles/README.md b/dotfiles/README.md new file mode 100644 index 0000000..72e96a9 --- /dev/null +++ b/dotfiles/README.md @@ -0,0 +1,94 @@ +# dotfiles + +Symlink-deployed config. The parent `~/setup_env/setup_env.sh` installs each file here to its destination path — edit the file in this repo, the change goes live. + +| Source in this repo | Deployed to | Deployed by | +| --- | --- | --- | +| `.bashrc`, `.vimrc`, `.gitconfig`, `.pspgconf`, `.psqlrc`, `.tmux.conf`, `.bashrc_local` | `~/` | `deploy_configs` | +| `bin/*` | `~/.local/bin/` | `deploy_bin` | +| `nvim/*.lua` | `~/.config/nvim/lua/.lua` | `deploy_nvim` | + +On a fresh machine: clone the `setup_env` repo, then from `~/setup_env/` run: + +```bash +./install_neovim.sh +./install_nvchad.sh +./install_python3.sh +./setup_env.sh # creates all the symlinks + installs apt packages +``` + +After install you still need to add one line to the NvChad-provided `~/.config/nvim/lua/mappings.lua` (it's not tracked here because NvChad owns that file): + +```lua +require("td_mappings") +``` + +Everything else picks up automatically. + +--- + +## td — markdown todo time tracking + +Track time on markdown todos identified by an Obsidian block-ref `^tid-*` at the end of the task line. Data lives in `time.csv` at the vault root; reports cross-join time with git history to show what was created vs. completed. + +### The three surfaces (one script) + +All logic is in `bin/td` — a Python 3 stdlib script. The other surfaces are thin wrappers so the script is reachable from each editing context. + +| Surface | Location | Purpose | +| --- | --- | --- | +| Python script | `bin/td` → `~/.local/bin/td` | Single source of truth. All subcommands. | +| Shell wrappers | `.bashrc` (`tstart` / `tstop` / `treport` / `tweek`) | Bypass the `td` alias (see gotcha). | +| Nvim module | `nvim/td.lua` + `nvim/td_mappings.lua` | `:TdStart` / `:TdStop` / `:TdReport` / `:TdWeek` commands + `t{s,p,r,w}` keymaps. | + +### Data model + +`time.csv` at the vault root: + +``` +started_at, stopped_at, tid, file, description +``` + +- `tid` — block-ref identifier, format `tid-YYYYMMDD-HHMMSS`, appended as `^tid-...` to the task line in the markdown file +- `started_at` / `stopped_at` — ISO-8601 local timestamps; a running entry has an empty `stopped_at` +- one row per time segment; `td stop` fills in `stopped_at` on the open row + +Vault root is discovered by walking up from the current buffer file looking for `time.csv` or `.obsidian/`. + +### Subcommands + +| Command | What it does | +| --- | --- | +| `td start [--file PATH] [--desc TEXT]` | Start a timer. Auto-stops any running entry first. If `--file` / `--desc` aren't given, greps the vault for the tid to populate them. | +| `td stop` | Close the open entry. | +| `td report [FILTER]` | Total time per tid (filter matches tid or file substring). | +| `td current` | Print the currently-running tid (or nothing). | +| `td tidgen` | Print a fresh `tid-YYYYMMDD-HHMMSS`. | +| `td week [--since DATE]` | Task create/complete events from `git log -p`, joined with `time.csv`. Default range: since Monday 00:00 of this week. | + +### How `td week` works + +Scans `git log -p --reverse --no-renames` from cwd, pairs `-`/`+` task-line diffs within each commit by tid, classifies each event: + +- `[x]` **done** — `- [ ]` → `- [x]` transition in one commit +- `[ ]` **new** — added `- [ ]` line with a fresh tid +- `[+]` **done+new** — `- [x]` added with no prior `- [ ]` for that tid (created and completed in the same commit) +- `[o]` **reopen** — `- [x]` → `- [ ]` transition + +Event timestamp is commit time, not toggle time — batched commits share one timestamp. Fine for weekly "what did I get done" reports; not precise enough for hourly auditing. + +### Nvim integration + +`ts` / `:TdStart` on a `- [ ]` task line: +1. If the line has no `^tid-*` block ref, auto-generates `tid-YYYYMMDD-HHMMSS`, appends it, saves the buffer. +2. Starts the timer with the file path (relative to vault root) and the task text (minus checkbox + block ref) as description. + +Other mappings: `tp` stop, `tr` report float, `tw` week float. `q` or `` closes a float. + +Known limitation: NvChad defers `mappings.lua` via `vim.schedule`, so `:TdStart` etc. aren't available inside `-c` arguments on headless invocations. Interactive use is fine. + +### Gotchas + +- **`td` alone is aliased to `rg`** (for the `td` / `tdp` / `tdo` todo-grep family, defined in `.bashrc`). So `td week` runs `rg "\- \[[^(x|~)]\]" week` and errors out. Use `tweek` (shell wrapper that calls `command td week`) or `command td week` directly. +- **CSV is cwd-scoped.** `$TD_LOG` defaults to `./time.csv`, so subcommands must be run from (or under) the vault root. The nvim wrapper handles this by walking up to find the vault root; the shell wrappers trust your cwd. +- **Data-model changes go in `bin/td`.** The shell and nvim surfaces should stay thin — if a new subcommand is useful in nvim too, add it to `bin/td`, then a one-line `tfoo()` wrapper in `.bashrc` and a `M.foo` + mapping in `nvim/td.lua` / `nvim/td_mappings.lua`. diff --git a/dotfiles/bin/td b/dotfiles/bin/td index 83bd00e..cddb1be 100755 --- a/dotfiles/bin/td +++ b/dotfiles/bin/td @@ -7,11 +7,12 @@ Usage: 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 + td week [--since DATE] # task create/complete events from git log, joined with time.csv Log location: $TD_LOG (default ./time.csv) """ import argparse, csv, os, re, subprocess, sys -from datetime import datetime +from datetime import datetime, timedelta from collections import defaultdict LOG = os.environ.get("TD_LOG", "./time.csv") @@ -113,6 +114,104 @@ def cmd_current(args): def cmd_tidgen(args): print(f"tid-{datetime.now().strftime('%Y%m%d-%H%M%S')}") +TID_RE = re.compile(r"\^(tid-[\w-]+)") +TASK_ADD_RE = re.compile(r"^\+(?!\+\+)\s*-\s*\[([ xX])\]\s*(.*)$") +TASK_REM_RE = re.compile(r"^-(?!--)\s*-\s*\[([ xX])\]\s*(.*)$") + +def _tid_totals(): + totals = defaultdict(int) + for r in read_rows(): + if r["stopped_at"]: + start = datetime.fromisoformat(r["started_at"]) + stop = datetime.fromisoformat(r["stopped_at"]) + totals[r["tid"]] += int((stop - start).total_seconds()) + return totals + +def _scan_git(since): + SEP = "---TDWEEK-COMMIT---" + try: + out = subprocess.check_output( + ["git", "log", f"--since={since}", "--reverse", + f"--format={SEP}%n%cI", "-p", "--no-color", "--no-renames"], + text=True, stderr=subprocess.DEVNULL, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return + for chunk in out.split(SEP + "\n"): + if not chunk.strip(): + continue + lines = chunk.split("\n") + ts = lines[0] + current_file = None + adds, removes = [], [] + for ln in lines[1:]: + if ln.startswith("+++ b/"): + current_file = ln[6:].split("\t")[0].rstrip(); continue + if ln.startswith("--- ") or ln.startswith("diff --git") or ln.startswith("index ") or ln.startswith("@@"): + continue + if not current_file or current_file == "/dev/null": + continue + m = TASK_ADD_RE.match(ln) + if m: + status = m.group(1).lower().strip() or " " + tm = TID_RE.search(m.group(2)) + if tm: + desc = TID_RE.sub("", m.group(2)).strip() + adds.append((current_file, status, tm.group(1), desc)) + continue + m = TASK_REM_RE.match(ln) + if m: + status = m.group(1).lower().strip() or " " + tm = TID_RE.search(m.group(2)) + if tm: + removes.append((current_file, status, tm.group(1))) + rem_by_tid = {t[2]: t for t in removes} + for file, status, tid, desc in adds: + prior = rem_by_tid.get(tid) + if prior: + if prior[1] == " " and status == "x": + yield (ts, "done", tid, file, desc) + elif prior[1] == "x" and status == " ": + yield (ts, "reopen", tid, file, desc) + else: + yield (ts, "done+new" if status == "x" else "new", tid, file, desc) + +def _default_since(): + today = datetime.now() + monday = today - timedelta(days=today.weekday()) + return monday.strftime("%Y-%m-%d 00:00") + +def _fmt_dur(secs): + return f"{secs // 3600}h{(secs % 3600) // 60:02d}m" + +def cmd_week(args): + since = args.since or _default_since() + events = list(_scan_git(since)) + totals = _tid_totals() + if not events: + print(f"no task events since {since}"); return + by_day = defaultdict(list) + for ts, kind, tid, file, desc in events: + day = ts[:10] + by_day[day].append((ts, kind, tid, file, desc)) + marker = {"done": "[x]", "new": "[ ]", "done+new": "[+]", "reopen": "[o]"} + n_done = n_new = total_secs = 0 + counted_tids = set() + print(f"Week since {since}\n") + for day in sorted(by_day): + dt = datetime.fromisoformat(day) + print(f"{dt.strftime('%a %Y-%m-%d')}") + for ts, kind, tid, file, desc in by_day[day]: + t = totals.get(tid, 0) + dur = _fmt_dur(t) if t else " " + print(f" {marker.get(kind,'? ')} {tid:<24} {dur:>6} {file} — {desc[:60]}") + if kind in ("done", "done+new"): n_done += 1 + if kind in ("new", "done+new"): n_new += 1 + if tid not in counted_tids: + counted_tids.add(tid); total_secs += t + print() + print(f"Totals: completed {n_done} created {n_new} time {_fmt_dur(total_secs)}") + def main(): p = argparse.ArgumentParser(prog="td", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = p.add_subparsers(dest="cmd", required=True) @@ -121,6 +220,7 @@ def main(): 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) + s = sp.add_parser("week"); s.add_argument("--since", default=None); s.set_defaults(func=cmd_week) args = p.parse_args() args.func(args) diff --git a/dotfiles/nvim/td.lua b/dotfiles/nvim/td.lua new file mode 100644 index 0000000..8361c33 --- /dev/null +++ b/dotfiles/nvim/td.lua @@ -0,0 +1,119 @@ +local M = {} + +local function vault_root() + local file = vim.api.nvim_buf_get_name(0) + local dir = file ~= "" and vim.fn.fnamemodify(file, ":p:h") or vim.fn.getcwd() + local probe = dir + while probe ~= "/" and probe ~= "" do + if vim.loop.fs_stat(probe .. "/time.csv") or vim.loop.fs_stat(probe .. "/.obsidian") then + return probe + end + local parent = vim.fn.fnamemodify(probe, ":h") + if parent == probe then break end + probe = parent + end + return vim.fn.getcwd() +end + +local function relpath(abs, base) + local a = vim.fn.fnamemodify(abs, ":p") + local b = vim.fn.fnamemodify(base, ":p"):gsub("/$", "") + if a:sub(1, #b + 1) == b .. "/" then return a:sub(#b + 2) end + return a +end + +local function run(cmd, cwd, on_done) + local stdout, stderr = {}, {} + vim.fn.jobstart(cmd, { + cwd = cwd, + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, d) for _, l in ipairs(d) do if l ~= "" then stdout[#stdout + 1] = l end end end, + on_stderr = function(_, d) for _, l in ipairs(d) do if l ~= "" then stderr[#stderr + 1] = l end end end, + on_exit = function(_, code) + vim.schedule(function() if on_done then on_done(stdout, stderr, code) end end) + end, + }) +end + +local function notify(lines, level) + if #lines == 0 then return end + vim.notify(table.concat(lines, "\n"), level or vim.log.levels.INFO) +end + +local function extract_desc(line) + return (line:gsub("^%s*%-%s*%[.%]%s*", ""):gsub("%s*%^%S+%s*$", ""):gsub("%s+$", "")) +end + +local function ensure_tid_on_line() + local line = vim.api.nvim_get_current_line() + local tid = line:match("%^(tid%-%S+)") + if tid then return tid end + if not line:match("^%s*%-%s*%[.%]") then + vim.notify("td: not on a todo line (- [ ] ...)", vim.log.levels.WARN) + return nil + end + tid = os.date("tid-%Y%m%d-%H%M%S") + local new = line:gsub("%s+$", "") .. " ^" .. tid + vim.api.nvim_set_current_line(new) + vim.cmd("silent! write") + return tid +end + +function M.start() + local tid = ensure_tid_on_line() + if not tid then return end + local cwd = vault_root() + local file = relpath(vim.api.nvim_buf_get_name(0), cwd) + local desc = extract_desc(vim.api.nvim_get_current_line()) + run({ "td", "start", tid, "--file", file, "--desc", desc }, cwd, function(out, err) + notify(out); notify(err, vim.log.levels.WARN) + end) +end + +function M.stop() + run({ "td", "stop" }, vault_root(), function(out, err) + notify(out); notify(err, vim.log.levels.WARN) + end) +end + +local function float(title, lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + local width = math.min(120, vim.o.columns - 4) + local height = math.min(30, #lines + 2) + vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + title = " " .. title .. " ", + title_pos = "center", + }) + vim.api.nvim_buf_set_keymap(buf, "n", "q", "close", { noremap = true, silent = true }) + vim.api.nvim_buf_set_keymap(buf, "n", "", "close", { noremap = true, silent = true }) +end + +function M.report() + run({ "td", "report" }, vault_root(), function(out, err) + if #out == 0 then notify(err, vim.log.levels.WARN); return end + float("td report", out) + end) +end + +function M.week(opts) + local cmd = { "td", "week" } + if opts and opts.args and opts.args ~= "" then + for w in opts.args:gmatch("%S+") do cmd[#cmd + 1] = w end + end + run(cmd, vault_root(), function(out, err) + if #out == 0 then notify(err, vim.log.levels.WARN); return end + float("td week", out) + end) +end + +return M diff --git a/dotfiles/nvim/td_mappings.lua b/dotfiles/nvim/td_mappings.lua new file mode 100644 index 0000000..8b4a2b4 --- /dev/null +++ b/dotfiles/nvim/td_mappings.lua @@ -0,0 +1,11 @@ +-- td: time-track markdown todos (logic lives in ~/.local/bin/td and td.lua) +-- Loaded from ~/.config/nvim/lua/mappings.lua via require("td_mappings"). +local td = require("td") +vim.api.nvim_create_user_command("TdStart", td.start, {}) +vim.api.nvim_create_user_command("TdStop", td.stop, {}) +vim.api.nvim_create_user_command("TdReport", td.report, {}) +vim.api.nvim_create_user_command("TdWeek", td.week, { nargs = "*" }) +vim.keymap.set("n", "ts", td.start, { desc = "td: start timer on current task" }) +vim.keymap.set("n", "tp", td.stop, { desc = "td: stop (pause) timer" }) +vim.keymap.set("n", "tr", td.report, { desc = "td: report floating window" }) +vim.keymap.set("n", "tw", td.week, { desc = "td: week (tasks from git log)" }) diff --git a/setup_env.sh b/setup_env.sh index 09a7d32..ccbde00 100755 --- a/setup_env.sh +++ b/setup_env.sh @@ -77,6 +77,23 @@ deploy_bin() { done } +# Deploy nvim lua modules from dotfiles/nvim/ into ~/.config/nvim/lua/ +# NOTE: requires ~/.config/nvim/ to already exist (see install_neovim.sh + install_nvchad.sh). +# Modules are required by name from ~/.config/nvim/lua/mappings.lua — e.g. require("td_mappings"). +deploy_nvim() { + echo "Deploying nvim lua modules to ~/.config/nvim/lua/ ..." + local src_dir="$(pwd)/dotfiles/nvim" + [[ -d "$src_dir" ]] || return 0 + if [[ ! -d ~/.config/nvim/lua ]]; then + echo " ~/.config/nvim/lua missing — run install_neovim.sh + install_nvchad.sh first. Skipping." + return 0 + fi + for mod in "$src_dir"/*.lua; do + [[ -f "$mod" ]] || continue + create_symlink "$mod" ~/.config/nvim/lua/"$(basename "$mod")" + done +} + # Main script main() { install_packages @@ -85,6 +102,7 @@ main() { install_git_bash_prompt deploy_configs deploy_bin + deploy_nvim echo "Setup complete! Please restart your shell or run 'source ~/.bashrc' for changes to take effect." }