weektag 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weektag/__init__.py +0 -0
- weektag/cli.py +262 -0
- weektag/export.py +95 -0
- weektag/ops.py +203 -0
- weektag/report.py +46 -0
- weektag/storage.py +116 -0
- weektag/timeutil.py +117 -0
- weektag/ulid.py +33 -0
- weektag-0.0.1.dist-info/METADATA +130 -0
- weektag-0.0.1.dist-info/RECORD +13 -0
- weektag-0.0.1.dist-info/WHEEL +4 -0
- weektag-0.0.1.dist-info/entry_points.txt +2 -0
- weektag-0.0.1.dist-info/licenses/LICENSE +21 -0
weektag/__init__.py
ADDED
|
File without changes
|
weektag/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""tt — the weektag command line interface (ADR 0005, 0008, 0009).
|
|
2
|
+
|
|
3
|
+
Output is plain text only: safe for pipes, agents, and Excel paste.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import export as export_mod
|
|
15
|
+
from . import ops, report, storage, timeutil
|
|
16
|
+
from .ops import WeektagError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="tt",
|
|
20
|
+
help="Tag-based time tracker with agent-readable weekly JSONL storage.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
pretty_exceptions_enable=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def complete_tag(incomplete: str) -> list[str]:
|
|
27
|
+
"""Dynamic tag completion from recent week files (ADR 0009)."""
|
|
28
|
+
return [t for t in ops.collect_recent_tags() if t.startswith(incomplete)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fail(message: str) -> None:
|
|
32
|
+
typer.echo(f"error: {message}", err=True)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hhmm(iso: str) -> str:
|
|
37
|
+
return dt.datetime.fromisoformat(iso).strftime("%H:%M")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _summary(rec: dict) -> str:
|
|
41
|
+
# ASCII separators only: Windows consoles may use narrow codepages (cp932)
|
|
42
|
+
tags = " ".join(rec.get("tags", []))
|
|
43
|
+
note = rec.get("note", "")
|
|
44
|
+
return f'{tags} "{note}"' if note else tags
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _echo_stopped(rec: dict) -> None:
|
|
48
|
+
hours = timeutil.fmt_hours(storage.duration_hours(rec))
|
|
49
|
+
typer.echo(f"stopped {_summary(rec)} ({hours} h)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TagsArg = Annotated[
|
|
53
|
+
list[str], typer.Argument(help="tags (no '#' needed)", autocompletion=complete_tag)
|
|
54
|
+
]
|
|
55
|
+
NoteOpt = Annotated[str, typer.Option("-m", "--note", help="free-form note")]
|
|
56
|
+
AtOpt = Annotated[str | None, typer.Option("--at", help="time like 9:00 (today)")]
|
|
57
|
+
WeekOpt = Annotated[str | None, typer.Option("--week", help="ISO week like 2026-W27")]
|
|
58
|
+
LastOpt = Annotated[bool, typer.Option("--last", help="previous week")]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_week(week: str | None, last: bool) -> str:
|
|
62
|
+
today = timeutil.now_local().date()
|
|
63
|
+
if week is not None:
|
|
64
|
+
return timeutil.parse_week(week)
|
|
65
|
+
if last:
|
|
66
|
+
return timeutil.last_week_key(today)
|
|
67
|
+
return timeutil.week_key(today)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def start(tags: TagsArg, note: NoteOpt = "", at: AtOpt = None) -> None:
|
|
72
|
+
"""Start a task (auto-stops a running one)."""
|
|
73
|
+
try:
|
|
74
|
+
rec, stopped = ops.start(tags, note=note, at=at)
|
|
75
|
+
except (WeektagError, ValueError) as e:
|
|
76
|
+
_fail(str(e))
|
|
77
|
+
if stopped is not None:
|
|
78
|
+
_echo_stopped(stopped)
|
|
79
|
+
typer.echo(f"started {_summary(rec)} at {_hhmm(rec['start'])} [{rec['id']}]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def stop(at: AtOpt = None) -> None:
|
|
84
|
+
"""Stop the running task."""
|
|
85
|
+
try:
|
|
86
|
+
rec = ops.stop(at=at)
|
|
87
|
+
except (WeektagError, ValueError) as e:
|
|
88
|
+
_fail(str(e))
|
|
89
|
+
_echo_stopped(rec)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def status() -> None:
|
|
94
|
+
"""Show the running task and elapsed time."""
|
|
95
|
+
running = ops.find_running()
|
|
96
|
+
if running is None:
|
|
97
|
+
typer.echo("no task is running")
|
|
98
|
+
return
|
|
99
|
+
_, rec = running
|
|
100
|
+
elapsed = (timeutil.now_local() - storage.start_dt(rec)).total_seconds() / 3600
|
|
101
|
+
typer.echo(
|
|
102
|
+
f"running: {_summary(rec)} "
|
|
103
|
+
f"(started {_hhmm(rec['start'])}, {timeutil.fmt_hours(elapsed)} h elapsed) [{rec['id']}]"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def resume() -> None:
|
|
109
|
+
"""Restart the previous task with the same tags and note."""
|
|
110
|
+
try:
|
|
111
|
+
rec = ops.resume()
|
|
112
|
+
except WeektagError as e:
|
|
113
|
+
_fail(str(e))
|
|
114
|
+
typer.echo(f"resumed {_summary(rec)} at {_hhmm(rec['start'])} [{rec['id']}]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def add(
|
|
119
|
+
range_: Annotated[str, typer.Argument(metavar="RANGE", help="interval like 9:00-10:30")],
|
|
120
|
+
tags: TagsArg,
|
|
121
|
+
note: NoteOpt = "",
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Add a past interval (today)."""
|
|
124
|
+
try:
|
|
125
|
+
rec = ops.add(range_, tags, note=note)
|
|
126
|
+
except (WeektagError, ValueError) as e:
|
|
127
|
+
_fail(str(e))
|
|
128
|
+
hours = timeutil.fmt_hours(storage.duration_hours(rec))
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"added {_summary(rec)} {_hhmm(rec['start'])}-{_hhmm(rec['stop'])} "
|
|
131
|
+
f"({hours} h) [{rec['id']}]"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def edit(
|
|
137
|
+
id_prefix: Annotated[str, typer.Argument(metavar="ID", help="record id (prefix ok)")],
|
|
138
|
+
start_: Annotated[
|
|
139
|
+
str | None, typer.Option("--start", help="new start (14:00 or 2026-07-06T14:00)")
|
|
140
|
+
] = None,
|
|
141
|
+
stop_: Annotated[
|
|
142
|
+
str | None, typer.Option("--stop", help="new stop (14:00 or 2026-07-06T14:00)")
|
|
143
|
+
] = None,
|
|
144
|
+
tags: Annotated[
|
|
145
|
+
str | None, typer.Option("--tags", help="replacement tags, space/comma separated")
|
|
146
|
+
] = None,
|
|
147
|
+
note: Annotated[str | None, typer.Option("-m", "--note", help="replacement note")] = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Edit a record by id prefix."""
|
|
150
|
+
tag_list = tags.replace(",", " ").split() if tags is not None else None
|
|
151
|
+
try:
|
|
152
|
+
rec = ops.edit(id_prefix, start=start_, stop=stop_, tags=tag_list, note=note)
|
|
153
|
+
except WeektagError as e:
|
|
154
|
+
_fail(str(e))
|
|
155
|
+
typer.echo(f"edited [{rec['id']}] {_summary(rec)}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def rm(
|
|
160
|
+
id_prefix: Annotated[str, typer.Argument(metavar="ID", help="record id (prefix ok)")],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Delete a record by id prefix."""
|
|
163
|
+
try:
|
|
164
|
+
rec = ops.remove(id_prefix)
|
|
165
|
+
except WeektagError as e:
|
|
166
|
+
_fail(str(e))
|
|
167
|
+
typer.echo(f"removed [{rec['id']}] {_summary(rec)}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def cancel() -> None:
|
|
172
|
+
"""Discard the running task without recording it."""
|
|
173
|
+
try:
|
|
174
|
+
rec = ops.cancel()
|
|
175
|
+
except WeektagError as e:
|
|
176
|
+
_fail(str(e))
|
|
177
|
+
typer.echo(f"cancelled {_summary(rec)} (started {_hhmm(rec['start'])})")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def log(week: WeekOpt = None, last: LastOpt = False) -> None:
|
|
182
|
+
"""Raw log with ids (the entry point for edit)."""
|
|
183
|
+
try:
|
|
184
|
+
key = _resolve_week(week, last)
|
|
185
|
+
except ValueError as e:
|
|
186
|
+
_fail(str(e))
|
|
187
|
+
for rec in ops.log_records(key):
|
|
188
|
+
start_ = storage.start_dt(rec)
|
|
189
|
+
stop_ = storage.stop_dt(rec)
|
|
190
|
+
if stop_ is None:
|
|
191
|
+
span, hours = f"{start_:%H:%M}-...", ""
|
|
192
|
+
else:
|
|
193
|
+
span = f"{start_:%H:%M}-{stop_:%H:%M}"
|
|
194
|
+
hours = timeutil.fmt_hours(storage.duration_hours(rec))
|
|
195
|
+
typer.echo(
|
|
196
|
+
f"{rec['id']} {start_.date().isoformat()} {span:>12} {hours:>6} {_summary(rec)}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("report")
|
|
201
|
+
def report_cmd(week: WeekOpt = None, last: LastOpt = False) -> None:
|
|
202
|
+
"""Per-tag summary for a week (default: this week)."""
|
|
203
|
+
try:
|
|
204
|
+
key = _resolve_week(week, last)
|
|
205
|
+
except ValueError as e:
|
|
206
|
+
_fail(str(e))
|
|
207
|
+
typer.echo(report.render(key), nl=False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("export")
|
|
211
|
+
def export_cmd(
|
|
212
|
+
week: WeekOpt = None,
|
|
213
|
+
last: LastOpt = False,
|
|
214
|
+
fmt: Annotated[str, typer.Option("--format", help="tsv or csv")] = "tsv",
|
|
215
|
+
output: Annotated[str | None, typer.Option("-o", "--output", help="write to file")] = None,
|
|
216
|
+
header: Annotated[
|
|
217
|
+
bool | None,
|
|
218
|
+
typer.Option("--header/--no-header", help="default: on for week export, off for --daily"),
|
|
219
|
+
] = None,
|
|
220
|
+
daily: Annotated[bool, typer.Option("--daily", help="3-column daily report preset")] = False,
|
|
221
|
+
date: Annotated[str | None, typer.Option("--date", help="daily target like 7/6")] = None,
|
|
222
|
+
noon: Annotated[str, typer.Option("--noon", help="AM/PM boundary")] = "12:00",
|
|
223
|
+
round_: Annotated[
|
|
224
|
+
float | None, typer.Option("--round", help="round hours to this increment, e.g. 0.25")
|
|
225
|
+
] = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Row-level TSV/CSV export, or the daily preset with --daily (ADR 0006, 0007)."""
|
|
228
|
+
if fmt not in ("tsv", "csv"):
|
|
229
|
+
_fail(f"unknown format {fmt!r} (expected tsv or csv)")
|
|
230
|
+
today = timeutil.now_local().date()
|
|
231
|
+
try:
|
|
232
|
+
if daily:
|
|
233
|
+
target = timeutil.parse_date(date, today) if date else today
|
|
234
|
+
rows = export_mod.daily_rows(
|
|
235
|
+
target, noon=timeutil.parse_time(noon), round_increment=round_
|
|
236
|
+
)
|
|
237
|
+
header_row = export_mod.DAILY_HEADER if header else None
|
|
238
|
+
else:
|
|
239
|
+
key = _resolve_week(week, last)
|
|
240
|
+
rows = export_mod.week_rows(key, round_increment=round_)
|
|
241
|
+
header_row = export_mod.WEEK_HEADER if header is None or header else None
|
|
242
|
+
except ValueError as e:
|
|
243
|
+
_fail(str(e))
|
|
244
|
+
content = export_mod.render(rows, header=header_row, fmt=fmt)
|
|
245
|
+
if output:
|
|
246
|
+
with open(output, "w", encoding="utf-8", newline="\n") as f:
|
|
247
|
+
f.write(content)
|
|
248
|
+
typer.echo(f"wrote {len(rows)} rows to {output}")
|
|
249
|
+
else:
|
|
250
|
+
typer.echo(content, nl=False)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> None:
|
|
254
|
+
# never crash on notes the console codepage can't render
|
|
255
|
+
for stream in (sys.stdout, sys.stderr):
|
|
256
|
+
if hasattr(stream, "reconfigure"):
|
|
257
|
+
stream.reconfigure(errors="replace")
|
|
258
|
+
app()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|
weektag/export.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""tt export — row-level TSV/CSV output and the daily preset (ADR 0006, 0007)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import io
|
|
8
|
+
|
|
9
|
+
from . import storage, timeutil
|
|
10
|
+
|
|
11
|
+
WEEK_HEADER = ["date", "start", "stop", "hours", "tags", "note"]
|
|
12
|
+
DAILY_HEADER = ["summary", "am", "pm"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt(hours: float, round_increment: float | None) -> str:
|
|
16
|
+
return timeutil.fmt_hours(timeutil.round_to(hours, round_increment))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def week_rows(week: str, round_increment: float | None = None) -> list[list[str]]:
|
|
20
|
+
rows = []
|
|
21
|
+
records = sorted(storage.read_week(week), key=lambda r: (r["start"], r["id"]))
|
|
22
|
+
for rec in records:
|
|
23
|
+
if storage.is_running(rec):
|
|
24
|
+
continue
|
|
25
|
+
start = storage.start_dt(rec)
|
|
26
|
+
stop = storage.stop_dt(rec)
|
|
27
|
+
rows.append(
|
|
28
|
+
[
|
|
29
|
+
start.date().isoformat(),
|
|
30
|
+
start.strftime("%H:%M"),
|
|
31
|
+
stop.strftime("%H:%M"),
|
|
32
|
+
_fmt(storage.duration_hours(rec), round_increment),
|
|
33
|
+
" ".join(rec.get("tags", [])),
|
|
34
|
+
rec.get("note", ""),
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def daily_rows(
|
|
41
|
+
date: dt.date,
|
|
42
|
+
noon: dt.time = dt.time(12, 0),
|
|
43
|
+
round_increment: float | None = None,
|
|
44
|
+
) -> list[list[str]]:
|
|
45
|
+
"""3-column daily preset: summary / AM / PM (ADR 0007).
|
|
46
|
+
|
|
47
|
+
Rows aggregate by (tag set + note); events are clipped to the target
|
|
48
|
+
date and split mechanically at the noon boundary.
|
|
49
|
+
"""
|
|
50
|
+
day_start = timeutil.local_dt(date, dt.time(0, 0))
|
|
51
|
+
day_end = timeutil.local_dt(date + dt.timedelta(days=1), dt.time(0, 0))
|
|
52
|
+
noon_dt = timeutil.local_dt(date, noon)
|
|
53
|
+
|
|
54
|
+
def overlap(a0: dt.datetime, a1: dt.datetime, b0: dt.datetime, b1: dt.datetime) -> float:
|
|
55
|
+
lo, hi = max(a0, b0), min(a1, b1)
|
|
56
|
+
return max((hi - lo).total_seconds(), 0) / 3600
|
|
57
|
+
|
|
58
|
+
# a day-spanning event may live in the previous week's file (ADR 0003)
|
|
59
|
+
weeks = {timeutil.week_key(date), timeutil.week_key(date - dt.timedelta(days=7))}
|
|
60
|
+
groups: dict[tuple, dict] = {}
|
|
61
|
+
for week in sorted(weeks):
|
|
62
|
+
for rec in storage.read_week(week):
|
|
63
|
+
if storage.is_running(rec):
|
|
64
|
+
continue
|
|
65
|
+
start, stop = storage.start_dt(rec), storage.stop_dt(rec)
|
|
66
|
+
if stop <= day_start or start >= day_end:
|
|
67
|
+
continue
|
|
68
|
+
key = (tuple(sorted(rec.get("tags", []))), rec.get("note", ""))
|
|
69
|
+
group = groups.setdefault(
|
|
70
|
+
key, {"am": 0.0, "pm": 0.0, "first": start, "tags": rec.get("tags", [])}
|
|
71
|
+
)
|
|
72
|
+
group["am"] += overlap(start, stop, day_start, noon_dt)
|
|
73
|
+
group["pm"] += overlap(start, stop, noon_dt, day_end)
|
|
74
|
+
if start < group["first"]:
|
|
75
|
+
group["first"] = start
|
|
76
|
+
group["tags"] = rec.get("tags", [])
|
|
77
|
+
|
|
78
|
+
rows = []
|
|
79
|
+
for (_, note), group in sorted(groups.items(), key=lambda kv: kv[1]["first"]):
|
|
80
|
+
summary = note or " ".join(group["tags"])
|
|
81
|
+
cells = []
|
|
82
|
+
for hours in (group["am"], group["pm"]):
|
|
83
|
+
rounded = timeutil.round_to(hours, round_increment)
|
|
84
|
+
cells.append(timeutil.fmt_hours(rounded) if rounded > 0 else "")
|
|
85
|
+
rows.append([summary, *cells])
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render(rows: list[list[str]], header: list[str] | None = None, fmt: str = "tsv") -> str:
|
|
90
|
+
all_rows = ([header] if header else []) + rows
|
|
91
|
+
if fmt == "csv":
|
|
92
|
+
buf = io.StringIO()
|
|
93
|
+
csv.writer(buf, lineterminator="\n").writerows(all_rows)
|
|
94
|
+
return buf.getvalue()
|
|
95
|
+
return "".join("\t".join(cell.replace("\t", " ") for cell in row) + "\n" for row in all_rows)
|
weektag/ops.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Recording operations (ADR 0002, 0005).
|
|
2
|
+
|
|
3
|
+
At most one running task at a time; a running task is a record without a
|
|
4
|
+
`stop` key. Everything is read-modify-write against the weekly JSONL files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
|
|
11
|
+
from . import storage, timeutil
|
|
12
|
+
from .ulid import mini_ulid
|
|
13
|
+
|
|
14
|
+
# ADR 0003: the running task is found by scanning recent week files.
|
|
15
|
+
RECENT_SCAN_WEEKS = 4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WeektagError(Exception):
|
|
19
|
+
"""User-facing error; the CLI prints the message and exits non-zero."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso(d: dt.datetime) -> str:
|
|
23
|
+
return d.isoformat(timespec="seconds")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clean_tags(tags: list[str]) -> list[str]:
|
|
27
|
+
"""Strip a quoted leading '#' (ADR 0004) and drop empties."""
|
|
28
|
+
cleaned = [t.lstrip("#").strip() for t in tags]
|
|
29
|
+
return [t for t in cleaned if t]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_running() -> tuple[str, dict] | None:
|
|
33
|
+
today = timeutil.now_local().date()
|
|
34
|
+
candidates = []
|
|
35
|
+
for key in timeutil.recent_week_keys(today, RECENT_SCAN_WEEKS):
|
|
36
|
+
for rec in storage.read_week(key):
|
|
37
|
+
if storage.is_running(rec):
|
|
38
|
+
candidates.append((key, rec))
|
|
39
|
+
if not candidates:
|
|
40
|
+
return None
|
|
41
|
+
return max(candidates, key=lambda kr: kr[1]["start"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_at(at: str | None) -> dt.datetime:
|
|
45
|
+
now = timeutil.now_local()
|
|
46
|
+
if at is None:
|
|
47
|
+
return now
|
|
48
|
+
return timeutil.at_time(now.date(), at)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _complete(key: str, rec: dict, stop: dt.datetime) -> dict:
|
|
52
|
+
start = storage.start_dt(rec)
|
|
53
|
+
if stop <= start:
|
|
54
|
+
raise WeektagError(
|
|
55
|
+
f"stop time {_iso(stop)} is not after start {_iso(start)} (record {rec['id']})"
|
|
56
|
+
)
|
|
57
|
+
records = storage.read_week(key)
|
|
58
|
+
for r in records:
|
|
59
|
+
if r["id"] == rec["id"]:
|
|
60
|
+
r["stop"] = _iso(stop)
|
|
61
|
+
rec = r
|
|
62
|
+
break
|
|
63
|
+
storage.write_week(key, records)
|
|
64
|
+
return rec
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def start(tags: list[str], note: str = "", at: str | None = None) -> tuple[dict, dict | None]:
|
|
68
|
+
tags = clean_tags(tags)
|
|
69
|
+
if not tags:
|
|
70
|
+
raise WeektagError("at least one tag is required")
|
|
71
|
+
start_time = _resolve_at(at)
|
|
72
|
+
stopped = None
|
|
73
|
+
running = find_running()
|
|
74
|
+
if running is not None:
|
|
75
|
+
stopped = _complete(running[0], running[1], start_time)
|
|
76
|
+
rec = {"id": mini_ulid(start_time), "start": _iso(start_time), "tags": tags, "note": note}
|
|
77
|
+
storage.append_record(rec)
|
|
78
|
+
return rec, stopped
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def stop(at: str | None = None) -> dict:
|
|
82
|
+
running = find_running()
|
|
83
|
+
if running is None:
|
|
84
|
+
raise WeektagError("no task is running")
|
|
85
|
+
return _complete(running[0], running[1], _resolve_at(at))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cancel() -> dict:
|
|
89
|
+
running = find_running()
|
|
90
|
+
if running is None:
|
|
91
|
+
raise WeektagError("no task is running")
|
|
92
|
+
key, rec = running
|
|
93
|
+
storage.write_week(key, [r for r in storage.read_week(key) if r["id"] != rec["id"]])
|
|
94
|
+
return rec
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resume() -> dict:
|
|
98
|
+
if find_running() is not None:
|
|
99
|
+
raise WeektagError("a task is already running (stop it first)")
|
|
100
|
+
today = timeutil.now_local().date()
|
|
101
|
+
completed = []
|
|
102
|
+
for key in timeutil.recent_week_keys(today, RECENT_SCAN_WEEKS):
|
|
103
|
+
completed.extend(r for r in storage.read_week(key) if not storage.is_running(r))
|
|
104
|
+
if not completed:
|
|
105
|
+
raise WeektagError("no previous task to resume")
|
|
106
|
+
last = max(completed, key=lambda r: r["stop"])
|
|
107
|
+
rec, _ = start(list(last["tags"]), note=last.get("note", ""))
|
|
108
|
+
return rec
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def add(range_str: str, tags: list[str], note: str = "") -> dict:
|
|
112
|
+
tags = clean_tags(tags)
|
|
113
|
+
if not tags:
|
|
114
|
+
raise WeektagError("at least one tag is required")
|
|
115
|
+
today = timeutil.now_local().date()
|
|
116
|
+
try:
|
|
117
|
+
start_time, stop_time = timeutil.parse_range(range_str, today)
|
|
118
|
+
except ValueError as e:
|
|
119
|
+
raise WeektagError(str(e)) from None
|
|
120
|
+
rec = {
|
|
121
|
+
"id": mini_ulid(start_time),
|
|
122
|
+
"start": _iso(start_time),
|
|
123
|
+
"stop": _iso(stop_time),
|
|
124
|
+
"tags": tags,
|
|
125
|
+
"note": note,
|
|
126
|
+
}
|
|
127
|
+
storage.append_record(rec)
|
|
128
|
+
return rec
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_by_prefix(prefix: str) -> tuple[str, dict]:
|
|
132
|
+
prefix = prefix.strip().upper()
|
|
133
|
+
if not prefix:
|
|
134
|
+
raise WeektagError("empty id")
|
|
135
|
+
matches = [
|
|
136
|
+
(key, rec)
|
|
137
|
+
for key in storage.all_week_keys()
|
|
138
|
+
for rec in storage.read_week(key)
|
|
139
|
+
if rec["id"].upper().startswith(prefix)
|
|
140
|
+
]
|
|
141
|
+
if not matches:
|
|
142
|
+
raise WeektagError(f"no record matching id {prefix!r}")
|
|
143
|
+
if len(matches) > 1:
|
|
144
|
+
ids = ", ".join(rec["id"] for _, rec in matches)
|
|
145
|
+
raise WeektagError(f"id {prefix!r} is ambiguous: {ids}")
|
|
146
|
+
return matches[0]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def edit(
|
|
150
|
+
prefix: str,
|
|
151
|
+
start: str | None = None,
|
|
152
|
+
stop: str | None = None,
|
|
153
|
+
tags: list[str] | None = None,
|
|
154
|
+
note: str | None = None,
|
|
155
|
+
) -> dict:
|
|
156
|
+
key, rec = find_by_prefix(prefix)
|
|
157
|
+
old_start = storage.start_dt(rec)
|
|
158
|
+
try:
|
|
159
|
+
if start is not None:
|
|
160
|
+
rec["start"] = _iso(timeutil.parse_datetime_arg(start, old_start.date()))
|
|
161
|
+
if stop is not None:
|
|
162
|
+
fallback = storage.stop_dt(rec) or storage.start_dt(rec)
|
|
163
|
+
rec["stop"] = _iso(timeutil.parse_datetime_arg(stop, fallback.date()))
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
raise WeektagError(str(e)) from None
|
|
166
|
+
if tags is not None:
|
|
167
|
+
cleaned = clean_tags(tags)
|
|
168
|
+
if not cleaned:
|
|
169
|
+
raise WeektagError("at least one tag is required")
|
|
170
|
+
rec["tags"] = cleaned
|
|
171
|
+
if note is not None:
|
|
172
|
+
rec["note"] = note
|
|
173
|
+
new_stop = storage.stop_dt(rec)
|
|
174
|
+
if new_stop is not None and new_stop <= storage.start_dt(rec):
|
|
175
|
+
raise WeektagError("stop time must be after start time")
|
|
176
|
+
new_key = storage.record_week_key(rec)
|
|
177
|
+
remaining = [r for r in storage.read_week(key) if r["id"] != rec["id"]]
|
|
178
|
+
if new_key == key:
|
|
179
|
+
storage.write_week(key, remaining + [rec])
|
|
180
|
+
else:
|
|
181
|
+
storage.write_week(key, remaining)
|
|
182
|
+
storage.append_record(rec)
|
|
183
|
+
return rec
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def remove(prefix: str) -> dict:
|
|
187
|
+
key, rec = find_by_prefix(prefix)
|
|
188
|
+
storage.write_week(key, [r for r in storage.read_week(key) if r["id"] != rec["id"]])
|
|
189
|
+
return rec
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def log_records(week: str) -> list[dict]:
|
|
193
|
+
return sorted(storage.read_week(week), key=lambda r: (r["start"], r["id"]))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def collect_recent_tags() -> list[str]:
|
|
197
|
+
"""Tag candidates for shell completion (ADR 0009)."""
|
|
198
|
+
today = timeutil.now_local().date()
|
|
199
|
+
tags: set[str] = set()
|
|
200
|
+
for key in timeutil.recent_week_keys(today, RECENT_SCAN_WEEKS):
|
|
201
|
+
for rec in storage.read_week(key):
|
|
202
|
+
tags.update(rec.get("tags", []))
|
|
203
|
+
return sorted(tags)
|
weektag/report.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""tt report — per-tag summary table for the terminal (ADR 0006).
|
|
2
|
+
|
|
3
|
+
Plain text only. A multi-tag event counts its full duration under each of
|
|
4
|
+
its tags, so the tag column may sum to more than the total row, which is
|
|
5
|
+
computed from real event time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
from . import storage, timeutil
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _completed(week: str) -> list[dict]:
|
|
17
|
+
return [r for r in storage.read_week(week) if not storage.is_running(r)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tag_totals(week: str) -> list[tuple[str, float]]:
|
|
21
|
+
totals: dict[str, float] = defaultdict(float)
|
|
22
|
+
for rec in _completed(week):
|
|
23
|
+
hours = storage.duration_hours(rec)
|
|
24
|
+
for tag in rec.get("tags", []):
|
|
25
|
+
totals[tag] += hours
|
|
26
|
+
return sorted(totals.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def total_hours(week: str) -> float:
|
|
30
|
+
return sum(storage.duration_hours(r) for r in _completed(week))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render(week: str) -> str:
|
|
34
|
+
totals = tag_totals(week)
|
|
35
|
+
monday = timeutil.week_monday(week)
|
|
36
|
+
sunday = monday + dt.timedelta(days=6)
|
|
37
|
+
title = f"{week} ({monday.isoformat()} - {sunday.isoformat()})"
|
|
38
|
+
if not totals:
|
|
39
|
+
return f"{title}\nno records\n"
|
|
40
|
+
width = max(len(tag) for tag, _ in totals + [("total", 0.0)])
|
|
41
|
+
lines = [title]
|
|
42
|
+
for tag, hours in totals:
|
|
43
|
+
lines.append(f"{tag:<{width}} {timeutil.fmt_hours(hours):>7}")
|
|
44
|
+
lines.append("-" * (width + 9))
|
|
45
|
+
lines.append(f"{'total':<{width}} {timeutil.fmt_hours(total_hours(week)):>7}")
|
|
46
|
+
return "\n".join(lines) + "\n"
|
weektag/storage.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Weekly JSONL storage — the only source of truth (ADR 0003).
|
|
2
|
+
|
|
3
|
+
No index, no DB, no hidden state. Writes are temp-file + atomic os.replace.
|
|
4
|
+
Files are hand-editable by users and agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import timeutil
|
|
18
|
+
|
|
19
|
+
_WEEK_FILE_RE = re.compile(r"^(\d{4}-W\d{2})\.jsonl$")
|
|
20
|
+
|
|
21
|
+
# JSON keys in canonical order (ADR 0004 schema)
|
|
22
|
+
_KEY_ORDER = ["id", "start", "stop", "tags", "note"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def data_dir() -> Path:
|
|
26
|
+
env = os.environ.get("WEEKTAG_DATA_DIR")
|
|
27
|
+
if env:
|
|
28
|
+
return Path(env)
|
|
29
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
30
|
+
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
|
31
|
+
return base / "weektag" / "events"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def week_path(key: str) -> Path:
|
|
35
|
+
return data_dir() / f"{key}.jsonl"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_week(key: str) -> list[dict]:
|
|
39
|
+
path = week_path(key)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return []
|
|
42
|
+
records = []
|
|
43
|
+
with path.open(encoding="utf-8") as f:
|
|
44
|
+
for line in f:
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if line:
|
|
47
|
+
records.append(json.loads(line))
|
|
48
|
+
return records
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dump(record: dict) -> str:
|
|
52
|
+
ordered = {k: record[k] for k in _KEY_ORDER if k in record}
|
|
53
|
+
ordered.update({k: v for k, v in record.items() if k not in _KEY_ORDER})
|
|
54
|
+
return json.dumps(ordered, ensure_ascii=False, separators=(",", ":"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_week(key: str, records: list[dict]) -> None:
|
|
58
|
+
path = week_path(key)
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
records = sorted(records, key=lambda r: (r["start"], r["id"]))
|
|
61
|
+
content = "".join(_dump(r) + "\n" for r in records)
|
|
62
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
65
|
+
f.write(content)
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
except BaseException:
|
|
68
|
+
with contextlib.suppress(OSError):
|
|
69
|
+
os.unlink(tmp)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def record_week_key(record: dict) -> str:
|
|
74
|
+
"""A record belongs to the week of its start's local date (ADR 0003)."""
|
|
75
|
+
return timeutil.week_key(start_dt(record))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def append_record(record: dict) -> str:
|
|
79
|
+
key = record_week_key(record)
|
|
80
|
+
records = read_week(key)
|
|
81
|
+
records.append(record)
|
|
82
|
+
write_week(key, records)
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def all_week_keys() -> list[str]:
|
|
87
|
+
d = data_dir()
|
|
88
|
+
if not d.exists():
|
|
89
|
+
return []
|
|
90
|
+
keys = []
|
|
91
|
+
for entry in d.iterdir():
|
|
92
|
+
m = _WEEK_FILE_RE.match(entry.name)
|
|
93
|
+
if m:
|
|
94
|
+
keys.append(m.group(1))
|
|
95
|
+
return sorted(keys)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def start_dt(record: dict) -> dt.datetime:
|
|
99
|
+
return dt.datetime.fromisoformat(record["start"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def stop_dt(record: dict) -> dt.datetime | None:
|
|
103
|
+
if "stop" not in record:
|
|
104
|
+
return None
|
|
105
|
+
return dt.datetime.fromisoformat(record["stop"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_running(record: dict) -> bool:
|
|
109
|
+
return "stop" not in record
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def duration_hours(record: dict) -> float:
|
|
113
|
+
stop = stop_dt(record)
|
|
114
|
+
if stop is None:
|
|
115
|
+
raise ValueError(f"record {record['id']} is still running")
|
|
116
|
+
return (stop - start_dt(record)).total_seconds() / 3600
|
weektag/timeutil.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""ISO-week and local-time helpers (ADR 0003, 0004).
|
|
2
|
+
|
|
3
|
+
All times are local + UTC offset; week membership is judged on the local
|
|
4
|
+
date. Weeks are ISO 8601, Monday-start (fixed, see ADR 0003).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_WEEK_RE = re.compile(r"^(\d{4})-[Ww](\d{1,2})$")
|
|
13
|
+
_TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
|
|
14
|
+
_MD_RE = re.compile(r"^(\d{1,2})/(\d{1,2})$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now_local() -> dt.datetime:
|
|
18
|
+
"""Current local time with UTC offset, no tzdata needed (ADR 0009)."""
|
|
19
|
+
return dt.datetime.now().astimezone()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def week_key(d: dt.date | dt.datetime) -> str:
|
|
23
|
+
if isinstance(d, dt.datetime):
|
|
24
|
+
d = d.date()
|
|
25
|
+
year, week, _ = d.isocalendar()
|
|
26
|
+
return f"{year}-W{week:02d}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_week(s: str) -> str:
|
|
30
|
+
m = _WEEK_RE.match(s.strip())
|
|
31
|
+
if not m:
|
|
32
|
+
raise ValueError(f"invalid week (expected like 2026-W27): {s!r}")
|
|
33
|
+
year, week = int(m.group(1)), int(m.group(2))
|
|
34
|
+
if not 1 <= week <= 53:
|
|
35
|
+
raise ValueError(f"invalid week number: {s!r}")
|
|
36
|
+
return f"{year}-W{week:02d}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def week_monday(key: str) -> dt.date:
|
|
40
|
+
year, week = parse_week(key).split("-W")
|
|
41
|
+
return dt.date.fromisocalendar(int(year), int(week), 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def last_week_key(today: dt.date) -> str:
|
|
45
|
+
return week_key(today - dt.timedelta(days=7))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def recent_week_keys(today: dt.date, n: int) -> list[str]:
|
|
49
|
+
return [week_key(today - dt.timedelta(weeks=i)) for i in range(n)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_time(s: str) -> dt.time:
|
|
53
|
+
m = _TIME_RE.match(s.strip())
|
|
54
|
+
if not m:
|
|
55
|
+
raise ValueError(f"invalid time (expected like 9:00): {s!r}")
|
|
56
|
+
hour, minute = int(m.group(1)), int(m.group(2))
|
|
57
|
+
if hour > 23 or minute > 59:
|
|
58
|
+
raise ValueError(f"invalid time: {s!r}")
|
|
59
|
+
return dt.time(hour, minute)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def local_dt(date: dt.date, time: dt.time) -> dt.datetime:
|
|
63
|
+
"""Combine into an aware datetime carrying the local offset for that date."""
|
|
64
|
+
return dt.datetime.combine(date, time).astimezone()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def at_time(date: dt.date, s: str) -> dt.datetime:
|
|
68
|
+
return local_dt(date, parse_time(s))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_range(s: str, date: dt.date) -> tuple[dt.datetime, dt.datetime]:
|
|
72
|
+
parts = s.split("-")
|
|
73
|
+
if len(parts) != 2:
|
|
74
|
+
raise ValueError(f"invalid range (expected like 9:00-10:30): {s!r}")
|
|
75
|
+
start = at_time(date, parts[0])
|
|
76
|
+
stop = at_time(date, parts[1])
|
|
77
|
+
if stop <= start: # overnight range rolls the stop to the next day
|
|
78
|
+
stop = local_dt(date + dt.timedelta(days=1), parse_time(parts[1]))
|
|
79
|
+
return start, stop
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_date(s: str, today: dt.date) -> dt.date:
|
|
83
|
+
s = s.strip()
|
|
84
|
+
m = _MD_RE.match(s)
|
|
85
|
+
if m:
|
|
86
|
+
return dt.date(today.year, int(m.group(1)), int(m.group(2)))
|
|
87
|
+
try:
|
|
88
|
+
return dt.date.fromisoformat(s)
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise ValueError(f"invalid date (expected like 7/6 or 2026-07-06): {s!r}") from None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_datetime_arg(s: str, fallback_date: dt.date) -> dt.datetime:
|
|
94
|
+
"""For edit: accept a bare time (uses the record's date) or a full ISO datetime."""
|
|
95
|
+
s = s.strip()
|
|
96
|
+
if _TIME_RE.match(s):
|
|
97
|
+
return at_time(fallback_date, s)
|
|
98
|
+
try:
|
|
99
|
+
parsed = dt.datetime.fromisoformat(s)
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"invalid datetime (expected like 14:00 or 2026-07-06T14:00): {s!r}"
|
|
103
|
+
) from None
|
|
104
|
+
if parsed.tzinfo is None:
|
|
105
|
+
parsed = parsed.astimezone()
|
|
106
|
+
return parsed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fmt_hours(hours: float) -> str:
|
|
110
|
+
"""Decimal hours, two places, no other rounding (ADR 0006)."""
|
|
111
|
+
return f"{hours:.2f}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def round_to(hours: float, increment: float | None) -> float:
|
|
115
|
+
if not increment:
|
|
116
|
+
return hours
|
|
117
|
+
return round(hours / increment) * increment
|
weektag/ulid.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Mini-ULID: 8-char time-sortable id (ADR 0004).
|
|
2
|
+
|
|
3
|
+
Layout: 6 Crockford-base32 chars of seconds since 2020-01-01 UTC (sortable
|
|
4
|
+
until 2054), followed by 2 random chars. Second-level precision is enough
|
|
5
|
+
for a single-user CLI; prefix matching is the primary lookup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import secrets
|
|
12
|
+
|
|
13
|
+
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
14
|
+
|
|
15
|
+
_EPOCH = dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
|
|
16
|
+
_TS_CHARS = 6
|
|
17
|
+
_RAND_CHARS = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _encode(value: int, width: int) -> str:
|
|
21
|
+
chars = []
|
|
22
|
+
for _ in range(width):
|
|
23
|
+
chars.append(CROCKFORD[value & 0x1F])
|
|
24
|
+
value >>= 5
|
|
25
|
+
return "".join(reversed(chars))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mini_ulid(now: dt.datetime | None = None) -> str:
|
|
29
|
+
if now is None:
|
|
30
|
+
now = dt.datetime.now().astimezone()
|
|
31
|
+
seconds = int((now - _EPOCH).total_seconds())
|
|
32
|
+
rand = secrets.randbelow(32**_RAND_CHARS)
|
|
33
|
+
return _encode(seconds, _TS_CHARS) + _encode(rand, _RAND_CHARS)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: weektag
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Tag-based CLI time tracker with agent-readable weekly JSONL storage
|
|
5
|
+
Project-URL: Repository, https://github.com/takeknock/weektag
|
|
6
|
+
Author: takeknock
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,jsonl,tags,time-tracking
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: typer>=0.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# weektag
|
|
20
|
+
|
|
21
|
+
Tag-based CLI time tracker with agent-readable weekly JSONL storage.
|
|
22
|
+
|
|
23
|
+
[日本語版 README](README.ja.md)
|
|
24
|
+
|
|
25
|
+
`weektag` records what you work on as start/stop intervals with flat tags, stores them
|
|
26
|
+
as plain JSONL files split by ISO week, and turns them into weekly reports or
|
|
27
|
+
TSV/CSV you can paste straight into Excel. The files are the whole database:
|
|
28
|
+
`cat`, `grep`, `jq`, and coding agents (Claude Code etc.) can read them with no
|
|
29
|
+
preprocessing — and hand-editing them is officially supported.
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ tt start writing client-a -m "blog draft"
|
|
33
|
+
started writing client-a "blog draft" at 09:00 [KZ3M2A7Q]
|
|
34
|
+
|
|
35
|
+
$ tt status
|
|
36
|
+
running: writing client-a "blog draft" (started 09:00, 1.50 h elapsed) [KZ3M2A7Q]
|
|
37
|
+
|
|
38
|
+
$ tt stop
|
|
39
|
+
stopped writing client-a "blog draft" (1.50 h)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
pipx install weektag # or: uv tool install weektag
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The installed command is `tt` (the package name `tt` was taken on PyPI).
|
|
49
|
+
Requires Python 3.11+.
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
tt start <tags...> [-m NOTE] [--at 9:00] # start (auto-stops a running task)
|
|
55
|
+
tt stop [--at 10:30] # stop
|
|
56
|
+
tt status # running task + elapsed time
|
|
57
|
+
tt resume # restart previous task (same tags/note)
|
|
58
|
+
tt add 9:00-10:30 <tags...> [-m NOTE] # add a past interval
|
|
59
|
+
tt edit <id-prefix> [--start] [--stop] [--tags] [-m]
|
|
60
|
+
tt rm <id-prefix> # delete a record
|
|
61
|
+
tt cancel # discard the running task
|
|
62
|
+
tt log [--week 2026-W27] # raw log with ids (entry point for edit)
|
|
63
|
+
tt report [--week 2026-W27 | --last] # per-tag weekly summary
|
|
64
|
+
tt export [--format csv] [-o FILE] [--no-header]
|
|
65
|
+
tt export --daily [--date 7/6] [--noon 13:00] [--round 0.25] [--header]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Only one task runs at a time: `tt start` while another task is running stops it
|
|
69
|
+
first. Forgot to start or stop? `add` / `edit` / `rm` fix the record afterwards.
|
|
70
|
+
|
|
71
|
+
Shell completion (bash / zsh / fish), including dynamic tag completion from your
|
|
72
|
+
recent records:
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
tt --install-completion
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Data format
|
|
79
|
+
|
|
80
|
+
One JSON object per line, one file per ISO week (Monday start), in
|
|
81
|
+
`~/.local/share/weektag/events/` (override with `WEEKTAG_DATA_DIR`;
|
|
82
|
+
`XDG_DATA_HOME` is honored):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{"id":"KZ3M2A7Q","start":"2026-07-06T09:00:00+09:00","stop":"2026-07-06T10:30:00+09:00","tags":["writing","client-a"],"note":"blog draft"}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- A running task is simply a record without a `stop` key.
|
|
89
|
+
- Times are local time with UTC offset; week membership follows the local date
|
|
90
|
+
of `start`. Week-spanning records are not split.
|
|
91
|
+
- The week files are the only source of truth — no hidden state, no index, no
|
|
92
|
+
database. Edit them by hand or with an agent whenever you like.
|
|
93
|
+
|
|
94
|
+
### Note on ISO week years
|
|
95
|
+
|
|
96
|
+
Files use ISO 8601 week numbering, which can differ from the calendar year at
|
|
97
|
+
year boundaries: a record on 2027-01-01 lives in `2026-W53.jsonl`. Commands
|
|
98
|
+
resolve this correctly; it only matters when you browse the files by hand.
|
|
99
|
+
|
|
100
|
+
## Excel workflow
|
|
101
|
+
|
|
102
|
+
`tt export` prints TSV rows (`date start stop hours tags note`) with decimal
|
|
103
|
+
hours (`1.50`), so a paste into Excel splits into columns and `SUM` just works.
|
|
104
|
+
|
|
105
|
+
`tt export --daily` is a preset for daily-report sheets: exactly three columns
|
|
106
|
+
(summary / AM hours / PM hours), aggregated per (tag set + note), split at noon
|
|
107
|
+
(`--noon` to change), no header by default so you can append to an existing
|
|
108
|
+
table day after day. There is no clipboard integration — pipe instead:
|
|
109
|
+
|
|
110
|
+
```console
|
|
111
|
+
tt export --daily | clip # Windows
|
|
112
|
+
tt export --daily | pbcopy # macOS
|
|
113
|
+
tt export --daily | xclip -sel c
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Command name collision
|
|
117
|
+
|
|
118
|
+
The `tt-time-tracker` package also installs a `tt` command. pipx / uv isolate
|
|
119
|
+
the environments, so only the PATH entry can collide. If you use both, rename
|
|
120
|
+
one with a shell alias, e.g. `alias wt=tt`.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
uv sync
|
|
126
|
+
uv run pytest
|
|
127
|
+
uv run ruff check . && uv run ruff format --check .
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
MIT license.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
weektag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
weektag/cli.py,sha256=HxrRC8B-TvUtrqaufJO55d_9p22y8gfbfUzk8k4WWOA,8577
|
|
3
|
+
weektag/export.py,sha256=lM-qHJqGM0-s0MNVCm3zTAFN4gS-GyEtqM4eJb-hGOA,3595
|
|
4
|
+
weektag/ops.py,sha256=pYMMHynwtN0krPCWyT8qeFODnVT14t-Zc3629S966-I,6491
|
|
5
|
+
weektag/report.py,sha256=OmS20eVAmSywyoGAJsUsqBLi5JWr-smpU5cdZTrRsQA,1543
|
|
6
|
+
weektag/storage.py,sha256=RvEHEu8L5ZrjkKNchsYleZHFPbHNzF_-Q3F6NRRmVN8,3132
|
|
7
|
+
weektag/timeutil.py,sha256=Nl9cdvoYgHfEsz5--e9tc0-OrmXI2jntqyDHiPPZiYw,3643
|
|
8
|
+
weektag/ulid.py,sha256=67SgE7vhfYKRtCqyNfN1MjaK_oqBFwxhUTb90pC-iX0,942
|
|
9
|
+
weektag-0.0.1.dist-info/METADATA,sha256=DffROOygj7yG3upa7__13gyysz0BcRwdBHzOpFxdI1w,4597
|
|
10
|
+
weektag-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
weektag-0.0.1.dist-info/entry_points.txt,sha256=sYFaBM6iAFTeajOX6qFG5EvW4SeFe9wtJO5acY9EEYs,40
|
|
12
|
+
weektag-0.0.1.dist-info/licenses/LICENSE,sha256=0nZQ5Ko8OQuVIhGmVT8PODxVlHmHLsDZpMjbTxu96Rc,1089
|
|
13
|
+
weektag-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takehiro M.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|