1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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()
|