#!/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 [--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()