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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tt = weektag.cli:main
@@ -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.