chore(tockr): bring range reporter into repo, track as bin/ source
dedad7428011bdee6b677872301679f2940b1672
Kevin Kortum <kevinkortum@me.com> · 2026-07-13 08:54
parent 9708b3ce
chore(tockr): bring range reporter into repo, track as bin/ source tockr (per-day `tock report --json` looped + merged, keeping notes/tags that `tock export` drops) lived only in ticktock-old; the ~/.local/bin and ~/bin symlinks pointed at ticktock/bin/tockr, which didn't exist here — dangling links broke every caller. Copy it in and change .gitignore from `bin/` to `bin/*` + `!bin/tockr` so the Python source is tracked while the compiled tt/winctx/cal-events binaries stay ignored. Verified against the sqlite backend: JSON, --by-day/--by-task/--by-week, notes+tags intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8Eefo7mxSGsHDhAyMDE1m
2 files changed
.gitignore +3 −1
@@ -1 +1,3 @@
-bin/
+bin/*
+# tockr is a source Python script (not a build artifact) — keep it tracked.
+!bin/tockr
bin/tockr +125 −0
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""tockr — tock entries for a date range, WITH notes and tags.
+
+`tock export` covers ranges but drops notes; `tock report --date` keeps notes
+but is single-day. This loops report per day and merges, so downstream tools
+(weekly-summary, monthly-invoice, the review TUI) get complete data.
+
+Usage:
+ tockr <from> <to> [--project NAME] [--by-day|--by-task|--by-week]
+ tockr week # current ISO week (Mon-Sun)
+ tockr month # current calendar month
+ tockr lastweek | lastmonth
+
+Default output: JSON array of entries. Aggregations print a text summary.
+"""
+import datetime as dt
+import json
+import subprocess
+import sys
+
+
+def report_day(day, project=None):
+ cmd = ["tock", "report", "--date", day.isoformat(), "--json"]
+ if project:
+ cmd += ["-p", project]
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ if r.returncode != 0 or not r.stdout.strip():
+ return []
+ try:
+ return json.loads(r.stdout)
+ except ValueError:
+ return []
+
+
+def dur_secs(s):
+ p = [int(x) for x in (s or "0").split(":")]
+ while len(p) < 3:
+ p.insert(0, 0)
+ return p[-3] * 3600 + p[-2] * 60 + p[-1]
+
+
+def daterange(a, b):
+ d = a
+ while d <= b:
+ yield d
+ d += dt.timedelta(days=1)
+
+
+def resolve(args):
+ """Map keyword shortcuts (week/month/...) to (from, to) dates."""
+ today = dt.date.today()
+ kw = args[0] if args else ""
+ if kw == "week":
+ s = today - dt.timedelta(days=today.weekday())
+ return s, s + dt.timedelta(days=6), args[1:]
+ if kw == "lastweek":
+ s = today - dt.timedelta(days=today.weekday() + 7)
+ return s, s + dt.timedelta(days=6), args[1:]
+ if kw == "month":
+ s = today.replace(day=1)
+ nm = (s.replace(day=28) + dt.timedelta(days=4)).replace(day=1)
+ return s, nm - dt.timedelta(days=1), args[1:]
+ if kw == "lastmonth":
+ first = today.replace(day=1)
+ end = first - dt.timedelta(days=1)
+ return end.replace(day=1), end, args[1:]
+ return dt.date.fromisoformat(args[0]), dt.date.fromisoformat(args[1]), args[2:]
+
+
+def fmt(secs):
+ h, m = secs // 3600, (secs % 3600) // 60
+ return f"{h}h {m}m"
+
+
+def main():
+ argv = sys.argv[1:]
+ if not argv:
+ sys.exit(__doc__)
+ project = None
+ if "--project" in argv:
+ i = argv.index("--project")
+ project = argv[i + 1]
+ argv = argv[:i] + argv[i + 2:]
+ mode = next((a for a in argv if a.startswith("--by-")), None)
+ argv = [a for a in argv if not a.startswith("--by-")]
+
+ start, end, _ = resolve(argv)
+ entries = []
+ for day in daterange(start, end):
+ entries += report_day(day, project)
+
+ if mode == "--by-day":
+ agg = {}
+ for e in entries:
+ agg.setdefault(e["start_time"][:10], 0)
+ agg[e["start_time"][:10]] += dur_secs(e.get("duration"))
+ print(f"{start} → {end}")
+ for day in sorted(agg):
+ print(f" {day} {fmt(agg[day]):>8} ({agg[day]/3600:.2f}h)")
+ print(f" TOTAL {fmt(sum(agg.values())):>8} ({sum(agg.values())/3600:.2f}h)")
+ elif mode == "--by-task":
+ agg = {}
+ for e in entries:
+ t = (e.get("tags") or [e.get("project", "-")])[0]
+ agg[t] = agg.get(t, 0) + dur_secs(e.get("duration"))
+ print(f"{start} → {end}")
+ for t, s in sorted(agg.items(), key=lambda x: -x[1]):
+ print(f" {t:<16} {fmt(s):>8} ({s/3600:.2f}h)")
+ print(f" TOTAL {fmt(sum(agg.values())):>8} ({sum(agg.values())/3600:.2f}h)")
+ elif mode == "--by-week":
+ agg = {}
+ for e in entries:
+ d = dt.date.fromisoformat(e["start_time"][:10])
+ wk = d - dt.timedelta(days=d.weekday())
+ agg[wk] = agg.get(wk, 0) + dur_secs(e.get("duration"))
+ print(f"{start} → {end}")
+ for wk in sorted(agg):
+ print(f" week of {wk} {fmt(agg[wk]):>8} ({agg[wk]/3600:.2f}h)")
+ print(f" TOTAL {fmt(sum(agg.values())):>8} ({sum(agg.values())/3600:.2f}h)")
+ else:
+ print(json.dumps(entries, indent=2))
+
+
+if __name__ == "__main__":
+ main()