▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0

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()