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:
parent
f27b5656c0
commit
fcf1132a61
@ -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'
|
||||
|
||||
94
dotfiles/README.md
Normal file
94
dotfiles/README.md
Normal 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`.
|
||||
102
dotfiles/bin/td
102
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)
|
||||
|
||||
|
||||
119
dotfiles/nvim/td.lua
Normal file
119
dotfiles/nvim/td.lua
Normal 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
|
||||
11
dotfiles/nvim/td_mappings.lua
Normal file
11
dotfiles/nvim/td_mappings.lua
Normal 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)" })
|
||||
18
setup_env.sh
18
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."
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user