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.
Files changed (47) hide show
  1. {worklog_for_claude_code-0.4.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.4.1}/PKG-INFO +2 -2
  2. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/README.md +1 -1
  3. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/pyproject.toml +1 -1
  4. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_platforms.py +33 -0
  5. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_smart.py +61 -1
  6. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/__init__.py +1 -1
  7. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/cli.py +11 -0
  8. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/report.py +77 -7
  9. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/services.py +42 -0
  10. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/timesheet.py +11 -12
  11. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/web.py +33 -19
  12. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1/worklog_for_claude_code.egg-info}/PKG-INFO +2 -2
  13. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/LICENSE +0 -0
  14. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/setup.cfg +0 -0
  15. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_accounts.py +0 -0
  16. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_chats.py +0 -0
  17. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_checks.py +0 -0
  18. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_demo.py +0 -0
  19. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_forget.py +0 -0
  20. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_syncstatus.py +0 -0
  21. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_updates.py +0 -0
  22. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_viz.py +0 -0
  23. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_watcher.py +0 -0
  24. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/tests/test_worklog.py +0 -0
  25. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/chats.py +0 -0
  26. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/checks.py +0 -0
  27. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/collector.py +0 -0
  28. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/config.py +0 -0
  29. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/demo.py +0 -0
  30. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/forget.py +0 -0
  31. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/hooks.py +0 -0
  32. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/identity.py +0 -0
  33. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/locks.py +0 -0
  34. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/overrides.py +0 -0
  35. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/parser.py +0 -0
  36. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/redact.py +0 -0
  37. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/store.py +0 -0
  38. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/syncstatus.py +0 -0
  39. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/tags.py +0 -0
  40. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/updates.py +0 -0
  41. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/viz.py +0 -0
  42. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog/watcher.py +0 -0
  43. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/SOURCES.txt +0 -0
  44. {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
  45. {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
  46. {worklog_for_claude_code-0.4.0 → worklog_for_claude_code-0.4.1}/worklog_for_claude_code.egg-info/requires.txt +0 -0
  47. {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.0
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.0"
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 == "Review"
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
@@ -1,3 +1,3 @@
1
1
  """claude-worklog: daily work log + token report for Claude Code."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
@@ -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, "likely_kind_of_work": p.tag,
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)}",\n'
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
- text = "; ".join(c["title"] for c in p.chat_items[:3])
373
- else:
374
- text = p.prompts[0][1].splitlines()[0] if p.prompts else "Work in Claude Code"
375
- return text if len(text) <= 120 else text[:119].rsplit(" ", 1)[0] + "…"
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, load_saved_summaries, plain_line
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
- saved = load_saved_summaries(store, d).get("summaries", {})
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
- s = saved.get(key) or {}
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
- line = mine or s.get("line") or ""
86
- if not line and s.get("bullets"):
87
- line = str(s["bullets"][0]).removeprefix("- ")
88
- summarized = bool(mine or (line and s.get("source") == "ai"))
89
- edited_tag = any(ov.tags.get(x["id"]) for x in p.sessions_list)
90
- rows.append(Row(d, p.name, key, p.minutes,
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
- load_saved_summaries,
50
+ legacy_fit,
51
51
  model_rows,
52
- plain_line,
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 = p.tag or "Feature"
741
- tag_opts = f'<option value="auto">Automatic ({e(auto)})</option>' + "".join(
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
- saved_meta = load_saved_summaries(store, day)
872
- saved = saved_meta.get("summaries", {})
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 = saved.get(p.key) or {}
979
- stale = ov.stale.get(f"{day.isoformat()}|{p.key}", 0) > summarized_at
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 not s:
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
- line = ov.lines.get(f"{day.isoformat()}|{p.key}") or s.get("line") or plain_line(p)
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
- edited_tag = any(ov.tags.get(x["id"]) for x in p.sessions_list)
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 or ov.lines.get(f"{day.isoformat()}|{p.key}") else "")
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
- saved = load_saved_summaries(store, day).get("summaries", {}).get(p.key) or {}
1950
- auto_line = saved.get("line") or plain_line(p)
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.0
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