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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-24 17:25:06 -04:00
parent f27b5656c0
commit fcf1132a61
6 changed files with 344 additions and 1 deletions

View File

@ -149,6 +149,7 @@ alias tdop='rg "\- \[[^(x|~)]\].*(🔼|⏫)" --line-number | fzf | awk -F: "{pri
tstart() { command td start "$@"; } tstart() { command td start "$@"; }
tstop() { command td stop; } tstop() { command td stop; }
treport() { command td report "$@"; } treport() { command td report "$@"; }
tweek() { command td week "$@"; }
alias gr='git reset HEAD' alias gr='git reset HEAD'
alias gc='git commit -v' alias gc='git commit -v'

94
dotfiles/README.md Normal file
View File

@ -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` | `~/<filename>` | `deploy_configs` |
| `bin/*` | `~/.local/bin/<name>` | `deploy_bin` |
| `nvim/*.lua` | `~/.config/nvim/lua/<name>.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 + `<leader>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 <tid> [--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
`<leader>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: `<leader>tp` stop, `<leader>tr` report float, `<leader>tw` week float. `q` or `<Esc>` 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`.

View File

@ -7,11 +7,12 @@ Usage:
td report [FILTER] # FILTER matches tid or file substring td report [FILTER] # FILTER matches tid or file substring
td current # print the currently-running tid (or nothing) td current # print the currently-running tid (or nothing)
td tidgen # print a new unique tid 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) Log location: $TD_LOG (default ./time.csv)
""" """
import argparse, csv, os, re, subprocess, sys import argparse, csv, os, re, subprocess, sys
from datetime import datetime from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
LOG = os.environ.get("TD_LOG", "./time.csv") LOG = os.environ.get("TD_LOG", "./time.csv")
@ -113,6 +114,104 @@ def cmd_current(args):
def cmd_tidgen(args): def cmd_tidgen(args):
print(f"tid-{datetime.now().strftime('%Y%m%d-%H%M%S')}") 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(): def main():
p = argparse.ArgumentParser(prog="td", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p = argparse.ArgumentParser(prog="td", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sp = p.add_subparsers(dest="cmd", required=True) 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) 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("current").set_defaults(func=cmd_current)
sp.add_parser("tidgen").set_defaults(func=cmd_tidgen) 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 = p.parse_args()
args.func(args) args.func(args)

119
dotfiles/nvim/td.lua Normal file
View File

@ -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", "<cmd>close<cr>", { noremap = true, silent = true })
vim.api.nvim_buf_set_keymap(buf, "n", "<Esc>", "<cmd>close<cr>", { 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

View File

@ -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", "<leader>ts", td.start, { desc = "td: start timer on current task" })
vim.keymap.set("n", "<leader>tp", td.stop, { desc = "td: stop (pause) timer" })
vim.keymap.set("n", "<leader>tr", td.report, { desc = "td: report floating window" })
vim.keymap.set("n", "<leader>tw", td.week, { desc = "td: week (tasks from git log)" })

View File

@ -77,6 +77,23 @@ deploy_bin() {
done 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 script
main() { main() {
install_packages install_packages
@ -85,6 +102,7 @@ main() {
install_git_bash_prompt install_git_bash_prompt
deploy_configs deploy_configs
deploy_bin deploy_bin
deploy_nvim
echo "Setup complete! Please restart your shell or run 'source ~/.bashrc' for changes to take effect." echo "Setup complete! Please restart your shell or run 'source ~/.bashrc' for changes to take effect."
} }