Compare commits

..

No commits in common. "cbb783bfdfd7f62becc897e5ff74be30a62c73e6" and "f27b5656c078ffbe4d8b6aafa20b5acf43dbb0d8" have entirely different histories.

7 changed files with 2 additions and 345 deletions

View File

@ -102,7 +102,7 @@ General:
- `nv` - Launch Neovim from custom installation path - `nv` - Launch Neovim from custom installation path
- `cj` - Navigate to journal directory - `cj` - Navigate to journal directory
- `jr` - Journal sync (pull, commit, push) - `jr` - Journal sync (pull, commit, push)
- `hc` - hc comapanies notes sync (pull, push) - `hc` - Health/care notes sync (pull, push)
### Plugin Managers ### Plugin Managers
- **Vim**: Vundle (installed to `~/.vim/bundle/Vundle.vim`) - **Vim**: Vundle (installed to `~/.vim/bundle/Vundle.vim`)

View File

@ -149,7 +149,6 @@ 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'

View File

@ -1,94 +0,0 @@
# 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,12 +7,11 @@ 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, timedelta from datetime import datetime
from collections import defaultdict from collections import defaultdict
LOG = os.environ.get("TD_LOG", "./time.csv") LOG = os.environ.get("TD_LOG", "./time.csv")
@ -114,104 +113,6 @@ 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)
@ -220,7 +121,6 @@ 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)

View File

@ -1,119 +0,0 @@
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

@ -1,11 +0,0 @@
-- 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,23 +77,6 @@ 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
@ -102,7 +85,6 @@ 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."
} }