worklog-for-claude-code 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {worklog_for_claude_code-0.4.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.4.1}/PKG-INFO +2 -2
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/README.md +1 -1
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/pyproject.toml +1 -1
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_platforms.py +33 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_smart.py +61 -1
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/__init__.py +1 -1
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/cli.py +11 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/report.py +77 -7
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/services.py +42 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/timesheet.py +11 -12
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/web.py +33 -19
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1/worklog_for_claude_code.egg-info}/PKG-INFO +2 -2
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/LICENSE +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/setup.cfg +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_accounts.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_chats.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_checks.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_demo.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_forget.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_syncstatus.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_updates.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_viz.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_watcher.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_worklog.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/chats.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/checks.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/collector.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/config.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/demo.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/forget.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/hooks.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/identity.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/locks.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/overrides.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/parser.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/redact.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/store.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/syncstatus.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/tags.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/updates.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/viz.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/watcher.py +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/SOURCES.txt +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/requires.txt +0 -0
- {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: worklog-for-claude-code
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default
|
|
5
5
|
Author: Urvin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,7 +123,7 @@ A checklist then shows anything still missing, with the exact command for your c
|
|
|
123
123
|
|
|
124
124
|
**Wrong project on a card?** Click **✏️ Edit** to rename it or move the sessions. Worklog can remember the folder you always start in.
|
|
125
125
|
|
|
126
|
-
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code
|
|
126
|
+
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code` and click **Restart now** on the page. Still the old version? [Restart it once](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md#i-updated-but-the-page-still-shows-the-old-version).
|
|
127
127
|
|
|
128
128
|
**Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
|
|
129
129
|
|
|
@@ -84,7 +84,7 @@ A checklist then shows anything still missing, with the exact command for your c
|
|
|
84
84
|
|
|
85
85
|
**Wrong project on a card?** Click **✏️ Edit** to rename it or move the sessions. Worklog can remember the folder you always start in.
|
|
86
86
|
|
|
87
|
-
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code
|
|
87
|
+
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code` and click **Restart now** on the page. Still the old version? [Restart it once](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md#i-updated-but-the-page-still-shows-the-old-version).
|
|
88
88
|
|
|
89
89
|
**Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
|
|
90
90
|
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
# Distribution name on PyPI. The command stays `claude-worklog` and the package stays `worklog`.
|
|
7
7
|
name = "worklog-for-claude-code"
|
|
8
|
-
version = "0.4.
|
|
8
|
+
version = "0.4.1"
|
|
9
9
|
description = "See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -283,3 +283,36 @@ def test_our_hook_is_recognised_in_every_form():
|
|
|
283
283
|
assert _is_ours(cmd), cmd
|
|
284
284
|
for cmd in ("some-other-tool", "claude-worklog-helper hook", "claude-worklog hooked", None):
|
|
285
285
|
assert not _is_ours(cmd), cmd
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------ "still running the old version" (doctor)
|
|
289
|
+
def test_port_and_restart_command_per_os(monkeypatch, fake, home):
|
|
290
|
+
monkeypatch.setattr(services.sys, "platform", "linux")
|
|
291
|
+
monkeypatch.setattr(services, "UNIT_DIR", home / ".config/systemd/user")
|
|
292
|
+
assert services.configured_port() == 8765 # not installed yet: the default
|
|
293
|
+
services.install(9100, report=False)
|
|
294
|
+
assert services.configured_port() == 9100
|
|
295
|
+
assert services.restart_hint() == "systemctl --user restart claude-worklog-web"
|
|
296
|
+
monkeypatch.setattr(services.sys, "platform", "darwin")
|
|
297
|
+
monkeypatch.setattr(services, "AGENT_DIR", home / "Library" / "LaunchAgents")
|
|
298
|
+
monkeypatch.setattr(services.os, "getuid", lambda: 501, raising=False)
|
|
299
|
+
services.install(9200, report=False)
|
|
300
|
+
assert services.configured_port() == 9200
|
|
301
|
+
assert services.restart_hint() == "launchctl kickstart -k gui/$(id -u)/dev.worklog.web"
|
|
302
|
+
monkeypatch.setattr(services.sys, "platform", "win32")
|
|
303
|
+
assert "sign out" in services.restart_hint()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_running_version_asks_the_page(monkeypatch):
|
|
307
|
+
import io
|
|
308
|
+
|
|
309
|
+
def page(req, timeout):
|
|
310
|
+
assert req.full_url == "http://127.0.0.1:8765/api/status"
|
|
311
|
+
return io.BytesIO(b'{"running": null, "version": "0.2.0"}')
|
|
312
|
+
monkeypatch.setattr("urllib.request.urlopen", page)
|
|
313
|
+
assert services.running_version(8765) == "0.2.0"
|
|
314
|
+
|
|
315
|
+
def down(req, timeout):
|
|
316
|
+
raise OSError("connection refused")
|
|
317
|
+
monkeypatch.setattr("urllib.request.urlopen", down)
|
|
318
|
+
assert services.running_version(8765) is None
|
|
@@ -363,7 +363,7 @@ def test_timesheet_rows_use_summaries_edits_and_filters(repo):
|
|
|
363
363
|
assert [(r.day.isoformat(), r.project, r.minutes) for r in rows] == [
|
|
364
364
|
("2026-11-02", "xoo", 50), ("2026-11-02", "R&D / misc", 40), ("2026-11-03", "xoo", 20)]
|
|
365
365
|
assert rows[0].summary == "Added the item quantity field" and rows[0].summarized and rows[0].tag == "Feature"
|
|
366
|
-
assert not rows[1].summarized and rows[1].tag == "
|
|
366
|
+
assert not rows[1].summarized and rows[1].tag == "" # no summary yet: no guessed tag
|
|
367
367
|
assert ts.missing_days(rows) == [_date(2026, 11, 2), _date(2026, 11, 3)]
|
|
368
368
|
overrides.change(store, "Office", {"lines": {"2026-11-02|misc:R&D / misc": "Code review of PR #505"},
|
|
369
369
|
"rules": {"~/development": "github.com/xoikospos/xoo"}})
|
|
@@ -572,3 +572,63 @@ def test_collect_end_to_end_review_started_outside_the_repo(home, repo):
|
|
|
572
572
|
x = projects["github.com/xoikospos/xoo"]
|
|
573
573
|
assert x.name == "xoo" and x.tag == "Review"
|
|
574
574
|
assert xoo.exists()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------------------ 0.4.1: the right summary, line and tag
|
|
578
|
+
from worklog.report import ProjectDay, pick_summary, plain_line, summary_line # noqa: E402
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _pd(prompts):
|
|
582
|
+
p = ProjectDay("k", "xoo", "git", None)
|
|
583
|
+
p.prompts = [("09:00", q) for q in prompts]
|
|
584
|
+
return p
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def test_timesheet_line_never_falls_back_to_a_one_word_prompt():
|
|
588
|
+
"""Screenshot: summaries from before 0.4.0 had no line, so "Approve" became the timesheet line."""
|
|
589
|
+
p = _pd(["Approve", "yes", "Pull every In Progress task assigned to me on the XOO board"])
|
|
590
|
+
assert plain_line(p) == "Pull every In Progress task assigned to me on the XOO board"
|
|
591
|
+
old = {"source": "ai", "bullets": ["- Reviewed the PRs listed as subtasks under XOO2-295. Then more.", "- x"]}
|
|
592
|
+
assert summary_line(old, p) == "Reviewed the PRs listed as subtasks under XOO2-295"
|
|
593
|
+
assert summary_line({"source": "ai", "line": "Reviewed PRs for XOO2-295"}, p) == "Reviewed PRs for XOO2-295"
|
|
594
|
+
assert summary_line(old, p, mine="My own line") == "My own line"
|
|
595
|
+
assert plain_line(_pd(["ok"])) == "ok" and plain_line(_pd([])) == "Work in Claude Code"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_a_summary_written_for_other_sessions_is_not_shown():
|
|
599
|
+
"""Screenshot: R&D / misc showed the PR #505 review summary, but that session had moved to xoo."""
|
|
600
|
+
metas = [{"generated_at": "2026-11-02T23:30:00+00:00",
|
|
601
|
+
"summaries": {"misc:R&D / misc": {"source": "ai", "bullets": ["- Reviewed PR #505"],
|
|
602
|
+
"sessions": ["pr505"]}}}]
|
|
603
|
+
assert pick_summary(metas, "misc:R&D / misc", ["mic"])[1] == "other"
|
|
604
|
+
assert pick_summary(metas, "misc:R&D / misc", ["pr505"])[1] == "ok"
|
|
605
|
+
assert pick_summary(metas, "misc:R&D / misc", ["pr505", "mic"])[1] == "partial"
|
|
606
|
+
assert pick_summary(metas, "nope", ["x"])[1] == "none"
|
|
607
|
+
legacy = [{"generated_at": "2026-11-01T23:30:00+00:00", "summaries": {"k": {"bullets": ["- a"]}}}]
|
|
608
|
+
assert pick_summary(legacy, "k", ["x"])[1] == "ok" # before 0.4.1 summaries didn't list their sessions
|
|
609
|
+
both = [{"generated_at": "2026-11-03", "summaries": {"k": {"sessions": ["a"], "line": "new, partial"}}},
|
|
610
|
+
{"generated_at": "2026-11-02", "summaries": {"k": {"sessions": ["a", "b"], "line": "older, complete"}}}]
|
|
611
|
+
assert pick_summary(both, "k", ["a", "b"])[0]["line"] == "older, complete" # the one that covers everything
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_cards_show_only_claudes_or_your_tag(day_app, repo):
|
|
615
|
+
app, cfg = day_app
|
|
616
|
+
body, _ = web.page_day(app, cfg, D1)
|
|
617
|
+
assert body.count('class="pill tag"') == 1 # xoo has Claude's "Feature"; the unsummarized card has none
|
|
618
|
+
overrides.change(GitStore(repo), "Office", {"tags": {"b": "Review"}})
|
|
619
|
+
assert web.page_day(app, cfg, D1)[0].count('class="pill tag"') == 2
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def test_old_misc_summary_is_hidden_after_its_session_moved_to_the_real_project(day_app, repo):
|
|
623
|
+
"""The exact screenshot: an old R&D / misc summary about PR #505, while misc now holds only a mic question."""
|
|
624
|
+
app, cfg = day_app
|
|
625
|
+
store = GitStore(repo)
|
|
626
|
+
moved = dict(XOO, detected_by="commands", started_in="~/development")
|
|
627
|
+
_put(store, "2026-11-02", "Office", "work", _rec("pr505", "14:00", "14:40", project=moved, active=40,
|
|
628
|
+
prompts=["/code-review 505 with proper check"]))
|
|
629
|
+
store.write_json("reports/2026-11-02/HP.json", {"generated_at": "2026-11-01T23:59:00+00:00", "summaries": {
|
|
630
|
+
"misc:R&D / misc": {"source": "ai", "bullets": ["- Reviewed PR #505 and posted a comment"]}}})
|
|
631
|
+
body, _ = web.page_day(app, cfg, D1)
|
|
632
|
+
assert "Reviewed PR #505 and posted a comment" not in body
|
|
633
|
+
assert "The saved summary was about other sessions" in body
|
|
634
|
+
assert "Summary out of date" in body # xoo's older summary misses the moved-in review
|
|
@@ -110,6 +110,17 @@ def cmd_doctor(args) -> int:
|
|
|
110
110
|
"Run `claude-worklog install-service` again.")
|
|
111
111
|
elif auto is not None:
|
|
112
112
|
print(f"{'✓' if auto else '!'} starts at login: {'yes' if auto else 'no (run claude-worklog install-service)'}")
|
|
113
|
+
if auto:
|
|
114
|
+
port = services.configured_port()
|
|
115
|
+
running = services.running_version(port)
|
|
116
|
+
if running is None:
|
|
117
|
+
print(f"! web page: not answering on port {port}. Restart it: {services.restart_hint()}")
|
|
118
|
+
elif running != __version__:
|
|
119
|
+
ok = False
|
|
120
|
+
print(f"✗ web page: still running {running}, but {__version__} is installed. "
|
|
121
|
+
f"Restart it once: {services.restart_hint()}")
|
|
122
|
+
else:
|
|
123
|
+
print(f"✓ web page: running {running} at http://worklog.localhost:{port}")
|
|
113
124
|
return 0 if ok else 1
|
|
114
125
|
|
|
115
126
|
|
|
@@ -116,6 +116,7 @@ class ProjectDay:
|
|
|
116
116
|
started_in: set[str] = field(default_factory=set) # home-relative folders the misc sessions started in
|
|
117
117
|
guess: dict | None = None # "Probably xoo?" for sessions that found no repo
|
|
118
118
|
moved: bool = False # sessions were moved here or a rule applied
|
|
119
|
+
detected: bool = False # a session here was found by 0.4.0's detection (it started outside this repo)
|
|
119
120
|
|
|
120
121
|
@property
|
|
121
122
|
def tag(self) -> str | None:
|
|
@@ -203,6 +204,7 @@ def aggregate(records: list[dict], ov: Overrides | None = None, time_mode: str =
|
|
|
203
204
|
p = projects[key] = ProjectDay(key, ov.names.get(key) or project_title(info), str(info.get("kind")),
|
|
204
205
|
info.get("remote"))
|
|
205
206
|
p.moved = p.moved or bool(moved_to)
|
|
207
|
+
p.detected = p.detected or (proj.get("detected_by") in ("cwd-other", "files", "commands") and not moved_to)
|
|
206
208
|
p.accounts.add(r.get("account", "?"))
|
|
207
209
|
p.devices.add(r.get("device", "?"))
|
|
208
210
|
if is_chat(r):
|
|
@@ -320,7 +322,7 @@ def ai_summary(p: ProjectDay, day: date, cfg: Config) -> dict | None:
|
|
|
320
322
|
return None
|
|
321
323
|
data = {
|
|
322
324
|
"project": p.name, "kind": p.kind, "branches": sorted(p.branches), "folders": sorted(p.folders),
|
|
323
|
-
"minutes_worked": p.minutes,
|
|
325
|
+
"minutes_worked": p.minutes,
|
|
324
326
|
"chats": [{"title": c["title"], "your_messages": c["prompts"], "chat_summary": c["summary"]}
|
|
325
327
|
for c in p.chat_items],
|
|
326
328
|
"prompts_in_order": [q for _, q in p.prompts],
|
|
@@ -342,7 +344,10 @@ def ai_summary(p: ProjectDay, day: date, cfg: Config) -> dict | None:
|
|
|
342
344
|
'triaged the mobile item-quantity bug\'",\n'
|
|
343
345
|
' "paragraph": "2-4 plain sentences about the day\'s work on this project",\n'
|
|
344
346
|
' "bullets": ["2-7 short points: what was built, fixed, reviewed or decided"],\n'
|
|
345
|
-
f' "tag": "one of: {", ".join(TAGS)}
|
|
347
|
+
f' "tag": "the ONE kind of work that took most of the time, one of: {", ".join(TAGS)}. Review = reviewing '
|
|
348
|
+
'pull requests or someone else\'s code, or triaging tickets; Bug fix = finding and fixing a problem; '
|
|
349
|
+
'Feature = building something new; Research = reading, asking or comparing without changing code; '
|
|
350
|
+
'Planning = deciding what to do or writing a plan; Release = shipping, deploying, versions or CI setup",\n'
|
|
346
351
|
' "next": ["open items still to do, if the data shows any; else an empty list"]}\n\n'
|
|
347
352
|
f"<data>\n{blob}\n</data>"
|
|
348
353
|
)
|
|
@@ -366,13 +371,77 @@ def ai_summary(p: ProjectDay, day: date, cfg: Config) -> dict | None:
|
|
|
366
371
|
return parsed
|
|
367
372
|
|
|
368
373
|
|
|
374
|
+
def _clip(text: str, n: int = 120) -> str:
|
|
375
|
+
text = " ".join(text.split())
|
|
376
|
+
return text if len(text) <= n else text[: n - 1].rsplit(" ", 1)[0].rstrip(",;:") + "…"
|
|
377
|
+
|
|
378
|
+
|
|
369
379
|
def plain_line(p: ProjectDay) -> str:
|
|
370
|
-
"""A one-line description without AI: the chat titles or the first prompt
|
|
380
|
+
"""A one-line description without AI: the chat titles, or the first prompt that says something
|
|
381
|
+
(one-word answers such as "Approve" or "yes" are skipped)."""
|
|
371
382
|
if p.chat_items:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return
|
|
383
|
+
return _clip("; ".join(c["title"] for c in p.chat_items[:3]))
|
|
384
|
+
firsts = [q.splitlines()[0].strip() for _, q in p.prompts if q.strip()]
|
|
385
|
+
good = next((q for q in firsts if len(q.split()) >= 4), firsts[0] if firsts else "Work in Claude Code")
|
|
386
|
+
return _clip(good)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def summary_line(s: dict | None, p: ProjectDay, mine: str | None = None) -> str:
|
|
390
|
+
"""The timesheet line: yours if you wrote one, else Claude's, else the first point of an older summary
|
|
391
|
+
(written before 0.4.0, without a line), else a plain line from your prompts."""
|
|
392
|
+
if mine:
|
|
393
|
+
return mine
|
|
394
|
+
s = s or {}
|
|
395
|
+
if s.get("line"):
|
|
396
|
+
return str(s["line"])
|
|
397
|
+
bullets = [str(b).removeprefix("- ") for b in s.get("bullets") or [] if str(b).strip()]
|
|
398
|
+
if s.get("source") == "ai" and bullets:
|
|
399
|
+
return _clip(bullets[0].split(". ")[0].rstrip("."))
|
|
400
|
+
return plain_line(p)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def summary_metas(store: GitStore, day: date) -> list[dict]:
|
|
404
|
+
"""Every device's saved summaries for `day`, newest first."""
|
|
405
|
+
metas = [m for m in store.iter_json(f"reports/{day.isoformat()}") if isinstance(m, dict)]
|
|
406
|
+
return sorted(metas, key=lambda m: str(m.get("generated_at", "")), reverse=True)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def legacy_fit(p: ProjectDay, projects: dict[str, ProjectDay]) -> str:
|
|
410
|
+
"""How far to trust a summary written before 0.4.1 (it doesn't say which sessions it covers).
|
|
411
|
+
|
|
412
|
+
0.4.0 started filing sessions that began outside a repo under their real project. On a day where that
|
|
413
|
+
happened, an older "R&D / misc" summary probably describes a session that has moved away ("other"),
|
|
414
|
+
and the project it moved into is missing it ("partial"). Other days are fine.
|
|
415
|
+
"""
|
|
416
|
+
if not any(q.detected for q in projects.values()):
|
|
417
|
+
return "ok"
|
|
418
|
+
if p.kind == "misc":
|
|
419
|
+
return "other"
|
|
420
|
+
return "partial" if p.detected else "ok"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def pick_summary(metas: list[dict], key: str, ids: list[str], legacy: str = "ok") -> tuple[dict, str, str]:
|
|
424
|
+
"""The saved summary that fits a card best: (summary, status, generated_at).
|
|
425
|
+
|
|
426
|
+
Summaries written from 0.4.0 on list the sessions they cover. status is "ok" when one covers all of
|
|
427
|
+
the card's sessions (or predates that list), "partial" when the best one covers only some (sessions
|
|
428
|
+
were added or moved in since), and "other" when it covers none of them: it describes different work,
|
|
429
|
+
for example after a session was moved to another project. Then the summary isn't shown at all.
|
|
430
|
+
"""
|
|
431
|
+
want = set(ids)
|
|
432
|
+
candidates = [(m, (m.get("summaries") or {}).get(key)) for m in metas]
|
|
433
|
+
candidates = [(m, sm) for m, sm in candidates if isinstance(sm, dict)]
|
|
434
|
+
if not candidates:
|
|
435
|
+
return {}, "none", ""
|
|
436
|
+
for m, sm in candidates: # newest first: the newest one that covers everything
|
|
437
|
+
covered = sm.get("sessions")
|
|
438
|
+
if not isinstance(covered, list):
|
|
439
|
+
return sm, legacy, str(m.get("generated_at", ""))
|
|
440
|
+
if want <= set(covered):
|
|
441
|
+
return sm, "ok", str(m.get("generated_at", ""))
|
|
442
|
+
m, sm = max(candidates, key=lambda c: (len(want & set(c[1].get("sessions") or [])), str(c[0].get("generated_at"))))
|
|
443
|
+
status = "partial" if want & set(sm.get("sessions") or []) else "other"
|
|
444
|
+
return sm, status, str(m.get("generated_at", ""))
|
|
376
445
|
|
|
377
446
|
|
|
378
447
|
def chat_line(c: dict) -> str:
|
|
@@ -410,6 +479,7 @@ def summarize_all(projects: dict[str, ProjectDay], day: date, cfg: Config, use_a
|
|
|
410
479
|
else:
|
|
411
480
|
out[key] = {"bullets": plain_summary(p), "source": "plain", "line": plain_line(p), "paragraph": "",
|
|
412
481
|
"tag": "", "next": []}
|
|
482
|
+
out[key]["sessions"] = sorted(x["id"] for x in p.sessions_list) # which sessions this summary covers
|
|
413
483
|
return out
|
|
414
484
|
|
|
415
485
|
|
|
@@ -494,6 +494,48 @@ def configured_python() -> str | None:
|
|
|
494
494
|
return None
|
|
495
495
|
|
|
496
496
|
|
|
497
|
+
def configured_port(default: int = 8765) -> int:
|
|
498
|
+
"""The port the installed web service listens on (from its unit, agent or Run entry)."""
|
|
499
|
+
import re
|
|
500
|
+
kind = system()
|
|
501
|
+
try:
|
|
502
|
+
if kind == "mac":
|
|
503
|
+
f = AGENT_DIR / f"{MAC_WEB}.plist"
|
|
504
|
+
text = " ".join(plistlib.loads(f.read_bytes())["ProgramArguments"]) if f.exists() else ""
|
|
505
|
+
elif kind == "windows":
|
|
506
|
+
text = _reg_get(RUN_VALUE) or ""
|
|
507
|
+
else:
|
|
508
|
+
f = UNIT_DIR / WEB
|
|
509
|
+
text = f.read_text(encoding="utf-8") if f.exists() else ""
|
|
510
|
+
except (OSError, ValueError, KeyError, plistlib.InvalidFileException):
|
|
511
|
+
text = ""
|
|
512
|
+
m = re.search(r"--port[ =](\d{2,5})", text)
|
|
513
|
+
return int(m.group(1)) if m else default
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def restart_hint() -> str:
|
|
517
|
+
"""How to restart the web page by hand on this system (needed once after updating from before 0.3.2)."""
|
|
518
|
+
kind = system()
|
|
519
|
+
if kind == "mac":
|
|
520
|
+
return f"launchctl kickstart -k gui/$(id -u)/{MAC_WEB}"
|
|
521
|
+
if kind == "windows":
|
|
522
|
+
return "sign out of Windows and back in (or restart the computer)"
|
|
523
|
+
return f"systemctl --user restart {WEB.removesuffix('.service')}"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def running_version(port: int, timeout: float = 2.0) -> str | None:
|
|
527
|
+
"""The version the web page on this computer is running right now, or None if it doesn't answer."""
|
|
528
|
+
import json
|
|
529
|
+
from urllib.request import Request, urlopen
|
|
530
|
+
try:
|
|
531
|
+
req = Request(f"http://127.0.0.1:{port}/api/status", headers={"Host": f"127.0.0.1:{port}"})
|
|
532
|
+
with urlopen(req, timeout=timeout) as r: # noqa: S310 - loopback only
|
|
533
|
+
v = json.loads(r.read(100_000).decode("utf-8")).get("version")
|
|
534
|
+
except (OSError, ValueError, AttributeError):
|
|
535
|
+
return None
|
|
536
|
+
return v if isinstance(v, str) else None
|
|
537
|
+
|
|
538
|
+
|
|
497
539
|
def report_timer_enabled() -> bool:
|
|
498
540
|
try:
|
|
499
541
|
return available() and backend().report_timer_enabled()
|
|
@@ -15,7 +15,7 @@ from datetime import date, timedelta
|
|
|
15
15
|
from xml.sax.saxutils import escape
|
|
16
16
|
|
|
17
17
|
from . import overrides
|
|
18
|
-
from .report import aggregate,
|
|
18
|
+
from .report import aggregate, legacy_fit, pick_summary, summary_line, summary_metas
|
|
19
19
|
from .store import GitStore
|
|
20
20
|
|
|
21
21
|
MAX_DAYS = 92
|
|
@@ -76,21 +76,20 @@ def build(store: GitStore, start: date, end: date, *, time_mode: str = "active",
|
|
|
76
76
|
if isinstance(r, dict) and (not account or r.get("account") == account)]
|
|
77
77
|
if records:
|
|
78
78
|
_, projects = aggregate(records, ov, time_mode)
|
|
79
|
-
|
|
79
|
+
metas = summary_metas(store, d)
|
|
80
80
|
for key, p in projects.items():
|
|
81
81
|
if project and key != project:
|
|
82
82
|
continue
|
|
83
|
-
|
|
83
|
+
sm, fit, _ = pick_summary(metas, key, [x["id"] for x in p.sessions_list], legacy_fit(p, projects))
|
|
84
|
+
if fit == "other":
|
|
85
|
+
sm = {}
|
|
84
86
|
mine = ov.lines.get(f"{d.isoformat()}|{key}")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
(p.tag if edited_tag else s.get("tag") or p.tag) or "",
|
|
92
|
-
line or plain_line(p), summarized, sorted(p.accounts), sorted(p.devices),
|
|
93
|
-
p.sessions + p.chats, sum(p.tokens.values()), p.approx))
|
|
87
|
+
summarized = bool(mine or sm.get("source") == "ai")
|
|
88
|
+
mine_tags = [ov.tags.get(x["id"]) for x in p.sessions_list if ov.tags.get(x["id"])]
|
|
89
|
+
tag = max(set(mine_tags), key=mine_tags.count) if mine_tags else sm.get("tag") or ""
|
|
90
|
+
rows.append(Row(d, p.name, key, p.minutes, tag, summary_line(sm, p, mine), summarized,
|
|
91
|
+
sorted(p.accounts), sorted(p.devices), p.sessions + p.chats, sum(p.tokens.values()),
|
|
92
|
+
p.approx))
|
|
94
93
|
d += timedelta(days=1)
|
|
95
94
|
rows.sort(key=lambda r: (r.day, -r.minutes, r.project))
|
|
96
95
|
return rows
|
|
@@ -47,12 +47,14 @@ from .identity import detected_logins, mask_email
|
|
|
47
47
|
from .report import (
|
|
48
48
|
aggregate,
|
|
49
49
|
build_report,
|
|
50
|
-
|
|
50
|
+
legacy_fit,
|
|
51
51
|
model_rows,
|
|
52
|
-
|
|
52
|
+
pick_summary,
|
|
53
53
|
plain_summary,
|
|
54
54
|
project_key,
|
|
55
55
|
project_title,
|
|
56
|
+
summary_line,
|
|
57
|
+
summary_metas,
|
|
56
58
|
)
|
|
57
59
|
from .store import GitStore
|
|
58
60
|
from .tags import TAGS
|
|
@@ -671,6 +673,15 @@ def _summary_age(meta: dict) -> float:
|
|
|
671
673
|
return 0.0
|
|
672
674
|
|
|
673
675
|
|
|
676
|
+
def card_tag(p, s: dict, ov) -> str | None:
|
|
677
|
+
"""The tag on a card: yours if you set one, else the one Claude chose when it wrote the summary.
|
|
678
|
+
Worklog's own guess from keywords isn't shown: it was often wrong (a review of PRs read as Research)."""
|
|
679
|
+
mine = [ov.tags.get(x["id"]) for x in p.sessions_list if ov.tags.get(x["id"])]
|
|
680
|
+
if mine:
|
|
681
|
+
return max(set(mine), key=mine.count)
|
|
682
|
+
return s.get("tag") or None
|
|
683
|
+
|
|
684
|
+
|
|
674
685
|
def _tasks_html(p, s: dict) -> str:
|
|
675
686
|
"""Claude's to-do items for this project and day, with Copy; or the summary's suggested next steps."""
|
|
676
687
|
if p.tasks:
|
|
@@ -709,7 +720,7 @@ def _guess_html(app: App, day: date, p) -> str:
|
|
|
709
720
|
f'<button class="quiet">No</button></form></span></div>')
|
|
710
721
|
|
|
711
722
|
|
|
712
|
-
def _edit_form(app: App, day: date, p, projects: dict, known: dict[str, str], ov, line: str) -> str:
|
|
723
|
+
def _edit_form(app: App, day: date, p, projects: dict, known: dict[str, str], ov, line: str, ai_tag: str = "") -> str:
|
|
713
724
|
"""The "Edit" popup for one card: rename, move (all or some sessions), folder rule, tag, timesheet line."""
|
|
714
725
|
c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
|
|
715
726
|
choices = [(k, q.name) for k, q in projects.items() if k != p.key]
|
|
@@ -737,8 +748,8 @@ def _edit_form(app: App, day: date, p, projects: dict, known: dict[str, str], ov
|
|
|
737
748
|
"".join(f'<option value="{e(f)}">{e(f)}</option>' for f in folders) + "</select>")
|
|
738
749
|
rule = (f'<div class="check"><input type="checkbox" id="rule" name="rule" value="1"><label for="rule">Always put '
|
|
739
750
|
f'sessions started in {pick} into that project on this computer (also for past days)</label></div>')
|
|
740
|
-
auto =
|
|
741
|
-
tag_opts = f'<option value="auto">
|
|
751
|
+
auto = f"Automatic ({ai_tag}, from the summary)" if ai_tag else "Automatic (set when the summary is written)"
|
|
752
|
+
tag_opts = f'<option value="auto">{e(auto)}</option>' + "".join(
|
|
742
753
|
f'<option value="{t}"{" selected" if any(ov.tags.get(x["id"]) == t for x in p.sessions_list) else ""}>{t}</option>'
|
|
743
754
|
for t in TAGS)
|
|
744
755
|
return f"""<section class="block edit-card" id="edit" aria-labelledby="edit-title">
|
|
@@ -868,8 +879,8 @@ def page_day(app: App, cfg: Config, day: date, edit_key: str | None = None) -> t
|
|
|
868
879
|
records = list(store.iter_json(f"records/{day.isoformat()}"))
|
|
869
880
|
ov = overrides.load(store.path)
|
|
870
881
|
accounts, projects = aggregate(records, ov, cfg.time_mode)
|
|
871
|
-
|
|
872
|
-
saved =
|
|
882
|
+
metas = summary_metas(store, day)
|
|
883
|
+
saved = metas[0].get("summaries", {}) if metas else {}
|
|
873
884
|
|
|
874
885
|
history_days = [day - timedelta(days=i) for i in reversed(range(HISTORY_DAYS))]
|
|
875
886
|
history, names = [], [a.name for a in cfg.accounts]
|
|
@@ -972,19 +983,23 @@ def page_day(app: App, cfg: Config, day: date, edit_key: str | None = None) -> t
|
|
|
972
983
|
'</div>')
|
|
973
984
|
|
|
974
985
|
entries = []
|
|
975
|
-
summarized_at = _summary_age(saved_meta)
|
|
976
986
|
edit_html = ""
|
|
977
987
|
for p in sorted(projects.values(), key=lambda p: (p.kind == "chat", p.kind == "misc", -sum(p.tokens.values()))):
|
|
978
|
-
s =
|
|
979
|
-
|
|
988
|
+
s, fit, written = pick_summary(metas, p.key, [x["id"] for x in p.sessions_list], legacy_fit(p, projects))
|
|
989
|
+
if fit == "other": # written for other sessions (moved since): don't show someone else's story
|
|
990
|
+
s = {}
|
|
991
|
+
stale = fit == "partial" or ov.stale.get(f"{day.isoformat()}|{p.key}", 0) > _summary_age({"generated_at": written})
|
|
980
992
|
bullets = s["bullets"] if s.get("bullets") else plain_summary(p)
|
|
981
|
-
if
|
|
993
|
+
if fit == "other":
|
|
994
|
+
src = "Your prompts. The saved summary was about other sessions: press “Rewrite summaries”."
|
|
995
|
+
elif not s:
|
|
982
996
|
src = "Your prompts. Press “Write summaries” for a written summary."
|
|
983
997
|
elif stale:
|
|
984
|
-
src = "Summary out of date: sessions were moved. Press “Rewrite summaries”."
|
|
998
|
+
src = "Summary out of date: sessions were moved or added. Press “Rewrite summaries”."
|
|
985
999
|
else:
|
|
986
1000
|
src = "Summary by Claude" if s.get("source") == "ai" else "Your prompts"
|
|
987
|
-
|
|
1001
|
+
mine = ov.lines.get(f"{day.isoformat()}|{p.key}")
|
|
1002
|
+
line = summary_line(s, p, mine)
|
|
988
1003
|
if s.get("paragraph"):
|
|
989
1004
|
items = (f'<p class="para">{e(s["paragraph"])}</p><details><summary>Details</summary><ul>'
|
|
990
1005
|
+ "".join(f"<li>{e(b[2:] if b.startswith('- ') else b)}</li>" for b in bullets) + "</ul></details>")
|
|
@@ -1017,20 +1032,19 @@ def page_day(app: App, cfg: Config, day: date, edit_key: str | None = None) -> t
|
|
|
1017
1032
|
if p.files:
|
|
1018
1033
|
extra += ('<details><summary>Files edited</summary><ul>'
|
|
1019
1034
|
+ "".join(f"<li><code>{e(f)}</code></li>" for f in sorted(p.files)[:60]) + "</ul></details>")
|
|
1020
|
-
|
|
1021
|
-
tag = (p.tag if edited_tag else s.get("tag") or p.tag) if p.sessions or p.chats else None
|
|
1035
|
+
tag = card_tag(p, s, ov)
|
|
1022
1036
|
edit_url = f"/?d={day.isoformat()}&edit={quote(p.key, safe='')}"
|
|
1023
1037
|
head_row = (f'<div class="cardhead"><h3>{e(p.name)}</h3>'
|
|
1024
1038
|
+ (f'<span class="pill tag">{e(tag)}</span>' if tag else "")
|
|
1025
1039
|
+ f'<a class="btn quiet edit" href="{edit_url}#edit" data-popup="edit" '
|
|
1026
1040
|
f'aria-label="Edit {e(p.name)}">✏️ Edit</a></div>')
|
|
1027
1041
|
tline = (f'<div class="tline"><span class="muted">Timesheet:</span> {e(line)}</div>'
|
|
1028
|
-
if s
|
|
1042
|
+
if s.get("source") == "ai" or mine else "")
|
|
1029
1043
|
entries.append(f'<article class="entry">{head_row}<div class="where">{where}</div>{_guess_html(app, day, p)}'
|
|
1030
1044
|
f'{items}{tline}{_tasks_html(p, s)}<div class="src">{e(src)}</div><dl class="facts">{dl}</dl>'
|
|
1031
1045
|
f'{extra}</article>')
|
|
1032
1046
|
if edit_key == p.key:
|
|
1033
|
-
edit_html = _edit_form(app, day, p, projects, known, ov, line)
|
|
1047
|
+
edit_html = _edit_form(app, day, p, projects, known, ov, line, s.get("tag") or "")
|
|
1034
1048
|
|
|
1035
1049
|
acct_rows = "".join(
|
|
1036
1050
|
f'<tr><td><i style="display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;'
|
|
@@ -1946,8 +1960,8 @@ class App:
|
|
|
1946
1960
|
edits["tags"].update({i: tag for i in ids if ov.tags.get(i) != tag})
|
|
1947
1961
|
line = " ".join(form.get("line", "").split())[:160]
|
|
1948
1962
|
if line != (ov.lines.get(here) or ""):
|
|
1949
|
-
|
|
1950
|
-
auto_line =
|
|
1963
|
+
picked, fit, _ = pick_summary(summary_metas(store, day), p.key, ids, legacy_fit(p, projects))
|
|
1964
|
+
auto_line = summary_line(picked if fit != "other" else {}, p)
|
|
1951
1965
|
edits["lines"][here] = None if not line or line == auto_line else line
|
|
1952
1966
|
edits = {k: v for k, v in edits.items() if v}
|
|
1953
1967
|
if edits:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: worklog-for-claude-code
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default
|
|
5
5
|
Author: Urvin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,7 +123,7 @@ A checklist then shows anything still missing, with the exact command for your c
|
|
|
123
123
|
|
|
124
124
|
**Wrong project on a card?** Click **✏️ Edit** to rename it or move the sessions. Worklog can remember the folder you always start in.
|
|
125
125
|
|
|
126
|
-
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code
|
|
126
|
+
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code` and click **Restart now** on the page. Still the old version? [Restart it once](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md#i-updated-but-the-page-still-shows-the-old-version).
|
|
127
127
|
|
|
128
128
|
**Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
|
|
129
129
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|