cctally 1.7.3 → 1.8.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.8.0] - 2026-05-18
9
+
10
+ ### Added
11
+ - dashboard envelope: new `daily.total_cost_usd` and `daily.total_tokens` fields exposed alongside the existing `daily.rows[]` so `DailyPanel` and downstream consumers can read the panel's total from the envelope instead of summing rows client-side. Backward-compatible additive change — consumers that ignore the new fields keep working. Sourced from the new `DailyView.total_cost_usd` / `DailyView.total_tokens` view-model fields populated by `build_daily_view` at envelope-construction time.
12
+
13
+ ### Changed
14
+ - view-model kernel: introduce `bin/_lib_view_models.py` as the single canonical builder for each report command's render dataset — adds `DailyView` + `build_daily_view`, `MonthlyView` + `build_monthly_view`, `WeeklyView` + `build_weekly_view`, `TrendView` + `build_trend_view`, and `SessionsView` + `build_sessions_view`. CLI consumers (`cmd_daily`, `cmd_monthly`, `cmd_weekly`, `cmd_report`, `cmd_session`), dashboard envelope builders (`_dashboard_build_*`), share-output snapshots (`_build_*_snapshot`), and TUI builders (`_tui_build_*`) now all route through the same kernel instead of re-aggregating from `iter_entries()` independently. Collapses the prior 3× re-totaling cost across CLI / dashboard / share for daily, monthly, weekly, and sessions; downstream renderers consume the dataclass directly and the camelCase dict workaround between `cmd_report` and the dashboard trend panel is removed. Refactor-only externally — behavior changes are limited to the additive envelope fields above and the credit-week footer fix below.
15
+ - Refine npm package description and README header for discoverability: front-load high-intent search terms ("Claude Code usage tracker", "dashboard", "Pro/Max subscription limits", "quota forecasts", "ccusage-compatible") while preserving the distinctive "cost-per-percent trend" hook. Swap five `package.json` keywords — drop generic noise (`usage`, `cost`, `tracker`, `llm`, `ai-tools`) and add high-intent terms (`claude-code-dashboard`, `claude-code-quota`, `claude-code-cost`, `ccusage-alternative`, `quota-tracking`). GitHub repo About and topics updated to match in lockstep. Description re-indexes on npm at next publish.
16
+
17
+ ### Fixed
18
+ - dashboard weekly panel: footer total now matches the synthesized rows on credit weeks. Pre-fix, the panel's row list included `_apply_midweek_reset_override`'s synthesized pre-credit + post-credit rows split at `effective_reset_at_utc`, but the footer total was read from `weekly_cost_snapshots.cost_usd` (the snapshotted total over the original whole week), so the displayed rows summed to a different number than the footer. Now the footer reads `WeeklyView.total_cost_usd`, which sums over the same synthesized rows the panel renders, so the two always match by construction.
19
+
20
+ ## [1.7.4] - 2026-05-17
21
+
22
+ ### Changed
23
+ - Extract leaf+mid kernel symbols (eprint, datetime/week helpers, alerts validation, open_db, make_week_ref, get_latest_usage_for_week) from `bin/cctally` into new `bin/_cctally_core.py`; rewrite ~200 sibling shim functions to honest top-level imports; the `c = _cctally()` accessor now survives only for residual Z-high cross-cutters (#50)
24
+
25
+ ### Fixed
26
+ - `cctally blocks`: round-5 Bug J follow-up — `_load_recorded_five_hour_windows` now sorts `credit_moments` ascending before the inner credit-pick loop. The pre-fix loop broke on the first matching credit in `[next_bs, rs]` regardless of order; with two in-place credits inside the same pre-credit canonical 5h window and SQLite returning rows in insertion order (no `ORDER BY` on `week_reset_events`), pair `(A, B)` could latch onto credit_2 instead of credit_1, collapsing two distinct truncated anchors onto the same 10-minute floor and silently dropping one via override-map overwrite — re-introducing the phantom heuristic `~` row Bug J was meant to eliminate. Sorting once ensures the break consistently picks the earliest credit, which by construction is the one whose floor equals the next block's `block_start_at`. P2 (not seen in production; every observed in-place credit so far is one-per-week). Regressions: `tests/test_in_place_credit_detection.py::test_load_recorded_five_hour_windows_truncates_each_overlap_independently` (exercises reverse-time SQL order to prove the order-dependent bug) + `test_group_entries_into_blocks_no_phantom_between_two_credits` (renderer-side defense in depth — three canonical blocks render with `recorded` anchor and no `~` row in the gap). Issue [#44](https://github.com/omrikais/cctally-dev/issues/44).
27
+ - `cctally record-usage`: in-place weekly credit race-defensive cleanup is now drift-tolerant. The DELETE that scrubs stale-replica snapshots out of the post-credit segment switched from strict `round(weekly_percent, 1) = round(prior_pct, 1)` equality to a `ABS(weekly_percent - ?) < 1.0` tolerance band around the observed pre-credit baseline. Today `claude-statusline` replays cctally's `hwm-7d` value byte-identically (its in-memory HWM equals the HWM we just wrote), so the existing strict predicate has worked — this is forward-looking defense. If Anthropic ever rounds the `--percent` payload differently from the OAuth API used by `record-usage`, or if `statusline` grows its own coarser rounding, a replay at e.g. `67.5` against a stored `prior_pct = 67.4` would slip past strict equality and then dominate the reset-aware clamp's MAX over the post-credit segment, masking legitimate post-credit values. 1.0pp band absorbs single-digit rounding drift; legitimate post-credit observations are ≥25pp away by the detection threshold's hypothesis so they're never caught. New schema migration `007_observed_pre_credit_pct` adds a nullable `observed_pre_credit_pct REAL` column to `week_reset_events`; the in-place credit INSERT stamps the value at write time so future cleanup tooling can re-derive the baseline without re-walking the snapshot stream. Simple `ALTER TABLE ADD COLUMN` — no UNIQUE constraint change, so no rename-recreate-copy (contrast migrations 005 / 006); live `CREATE TABLE` updated in lockstep so fresh installs land the new column directly via the dispatcher's fresh-install fast-stamp. Regression: `tests/test_in_place_credit_detection.py::test_credit_branch_cleanup_tolerates_rounding_drift` reproduces the drift scenario (stale replay at 67.5 vs prior_pct=67.4) — fails under strict-equality, passes under the band. P2 defensive hardening; not seen in production. Issue [#45](https://github.com/omrikais/cctally-dev/issues/45).
28
+ - `cctally record-usage`: in-place 5h credit race-defensive cleanup is now drift-tolerant — symmetric follow-up to the weekly fix above. The DELETE that scrubs stale-replica snapshots out of the post-credit 5h segment switched from strict `round(five_hour_percent, 1) = round(prior_5h_pct, 1)` equality to a `ABS(five_hour_percent - ?) < 1.0` tolerance band around the observed pre-credit baseline. Pure predicate change — no migration needed because `five_hour_reset_events.prior_percent` (stamped at write time since v1.7.3, issue #43) already provides the durable pre-credit value that the weekly variant required migration 007 to add. Same forward-looking failure mode as the weekly path: if Anthropic ever rounds the `--five-hour-percent` payload differently from the OAuth API used by `record-usage`, or if `statusline` grows its own coarser rounding for the 5h dimension, a replay at e.g. `27.5` against a stored `prior_5h_pct = 27.4` would slip past strict equality and then dominate the reset-aware 5h clamp's MAX over the post-credit segment, masking legitimate post-credit 5h values. The 1.0pp band stays 4pp below the 5.0pp `_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD` so legitimate post-credit observations (≥5pp away from `prior_5h_pct` by the detection threshold's hypothesis) are never caught. Regression: `tests/test_in_place_5h_credit_detection.py::test_credit_branch_5h_cleanup_tolerates_rounding_drift` reproduces the drift scenario (stale 27.5 replay vs `prior_5h_pct=27.4` → reset-aware 5h clamp masks the legitimate 4.0% post-credit seed up to 27.5 under strict equality; the band catches the replay and the seed lands). P2 defensive hardening; not seen in production. Issue [#48](https://github.com/omrikais/cctally-dev/issues/48).
29
+
8
30
  ## [1.7.3] - 2026-05-16
9
31
 
10
32
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <strong>Track Claude Code subscription usage as a weekly $-per-1% trend. Local web dashboard, terminal UI, forecasts, and threshold alerts.</strong>
9
+ <strong>Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.</strong>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -76,6 +76,14 @@ _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
76
76
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
77
77
 
78
78
 
79
+ # === Honest imports from extracted homes ===================================
80
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
81
+ # import from _cctally_core. `LOG_DIR` stays on the _cctally() accessor
82
+ # per Q1=B (path constants propagate via monkeypatch.setitem against the
83
+ # cctally namespace).
84
+ from _cctally_core import now_utc_iso
85
+
86
+
79
87
  def _alerts_log_path() -> "pathlib.Path":
80
88
  """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
81
89
 
@@ -167,7 +175,7 @@ def _dispatch_alert_notification(
167
175
  or ""
168
176
  )
169
177
  line = (
170
- f"{_cctally().now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
178
+ f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
171
179
  f"\t{mode}\t{status}\n"
172
180
  )
173
181
  with open(log_path, "a") as f:
@@ -194,7 +202,6 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
194
202
  2 --threshold out of [1, 100] range
195
203
  3 other spawn error (PermissionError, OSError, ...)
196
204
  """
197
- c = _cctally()
198
205
  axis = "weekly" if args.axis == "weekly" else "five_hour"
199
206
  threshold = int(args.threshold)
200
207
  if not (1 <= threshold <= 100):
@@ -206,7 +213,7 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
206
213
  if axis == "weekly":
207
214
  payload = _build_alert_payload_weekly(
208
215
  threshold=threshold,
209
- crossed_at_utc=c.now_utc_iso(),
216
+ crossed_at_utc=now_utc_iso(),
210
217
  week_start_date=dt.date.today().isoformat(),
211
218
  cumulative_cost_usd=1.23,
212
219
  dollars_per_percent=0.01,
@@ -214,9 +221,9 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
214
221
  else:
215
222
  payload = _build_alert_payload_five_hour(
216
223
  threshold=threshold,
217
- crossed_at_utc=c.now_utc_iso(),
224
+ crossed_at_utc=now_utc_iso(),
218
225
  five_hour_window_key=int(dt.datetime.now(dt.timezone.utc).timestamp()),
219
- block_start_at=c.now_utc_iso(),
226
+ block_start_at=now_utc_iso(),
220
227
  block_cost_usd=1.23,
221
228
  primary_model="claude-sonnet-4-6",
222
229
  )
@@ -118,17 +118,18 @@ def _cctally():
118
118
  return sys.modules["cctally"]
119
119
 
120
120
 
121
- # Module-level back-ref shims for the four bare-name helpers consumed
122
- # throughout the moved bodies. Each shim resolves
123
- # ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
124
- # monkeypatches on cctally's namespace propagate into the moved code
125
- # unchanged. Mirrors the precedent established in ``bin/_cctally_db.py``
126
- # (``now_utc_iso`` / ``parse_iso_datetime`` / ``_compute_block_totals``
127
- # / ``eprint`` shims).
128
- def eprint(*args, **kwargs):
129
- return sys.modules["cctally"].eprint(*args, **kwargs)
130
-
131
-
121
+ # === Honest imports from extracted homes ===================================
122
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
123
+ # (Z-leaf + Z-mid) import from _cctally_core. The legacy shim function
124
+ # for ``eprint`` is deleted.
125
+ from _cctally_core import eprint
126
+
127
+
128
+ # Module-level back-ref shims for the three out-of-scope JSONL/project
129
+ # discovery helpers that STAY in bin/cctally per spec §3.7. Each shim
130
+ # resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind time),
131
+ # so monkeypatches on cctally's namespace propagate into the moved code
132
+ # unchanged.
132
133
  def _decode_escaped_cwd(*args, **kwargs):
133
134
  return sys.modules["cctally"]._decode_escaped_cwd(*args, **kwargs)
134
135
 
@@ -48,6 +48,25 @@ def _cctally():
48
48
  return sys.modules["cctally"]
49
49
 
50
50
 
51
+ # === Honest imports from extracted homes ===================================
52
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
53
+ # import from _cctally_core; the Bucket-X helper `normalize_display_tz_value`
54
+ # imports from `_lib_display_tz`. Path constants (`CONFIG_PATH`,
55
+ # `CONFIG_LOCK_PATH`) plus out-of-scope validators
56
+ # (`_normalize_alerts_enabled_value`, `_validate_dashboard_bind_value`,
57
+ # `_validate_update_check_ttl_hours_value`, `_normalize_update_check_enabled_value`,
58
+ # `get_display_tz_pref`, `UPDATE_DEFAULT_TTL_HOURS`) stay on the
59
+ # _cctally() accessor.
60
+ from _cctally_core import (
61
+ eprint,
62
+ ensure_dirs,
63
+ DEFAULT_WEEK_START,
64
+ _get_alerts_config,
65
+ _AlertsConfigError,
66
+ )
67
+ from _lib_display_tz import normalize_display_tz_value
68
+
69
+
51
70
  _CONFIG_CORRUPT_WARNED = False # one-shot warn flag for load_config
52
71
 
53
72
 
@@ -61,20 +80,19 @@ def _warn_config_corrupt_once(reason: str) -> None:
61
80
  return
62
81
  _CONFIG_CORRUPT_WARNED = True
63
82
  c = _cctally()
64
- c.eprint(
83
+ eprint(
65
84
  f"warning: ignoring corrupt {c.CONFIG_PATH} ({reason}); "
66
85
  "using in-memory defaults"
67
86
  )
68
87
 
69
88
 
70
89
  def _default_config_data() -> dict[str, Any]:
71
- c = _cctally()
72
90
  return {
73
91
  "collector": {
74
92
  "host": "127.0.0.1",
75
93
  "port": 17321,
76
94
  "token": secrets.token_hex(16),
77
- "week_start": c.DEFAULT_WEEK_START,
95
+ "week_start": DEFAULT_WEEK_START,
78
96
  }
79
97
  }
80
98
 
@@ -122,7 +140,7 @@ def config_writer_lock():
122
140
  either the pre-rename or post-rename file, never partial bytes.
123
141
  """
124
142
  c = _cctally()
125
- c.ensure_dirs()
143
+ ensure_dirs()
126
144
  c.CONFIG_LOCK_PATH.touch()
127
145
  fh = open(c.CONFIG_LOCK_PATH, "w")
128
146
  try:
@@ -154,7 +172,7 @@ def load_config() -> dict[str, Any]:
154
172
  #17 fix).
155
173
  """
156
174
  c = _cctally()
157
- c.ensure_dirs()
175
+ ensure_dirs()
158
176
  parsed = _try_read_config()
159
177
  if parsed is not None:
160
178
  return parsed
@@ -186,8 +204,7 @@ def _load_config_unlocked() -> dict[str, Any]:
186
204
  do its own save_config call atomically. Corrupt-file path returns
187
205
  in-memory defaults (caller's save will overwrite cleanly).
188
206
  """
189
- c = _cctally()
190
- c.ensure_dirs()
207
+ ensure_dirs()
191
208
  parsed = _try_read_config()
192
209
  if parsed is not None:
193
210
  return parsed
@@ -208,7 +225,7 @@ def save_config(data: dict[str, Any]) -> None:
208
225
  not the read-modify-write semantics of `cctally config set`.
209
226
  """
210
227
  c = _cctally()
211
- c.ensure_dirs()
228
+ ensure_dirs()
212
229
  payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
213
230
  tmp = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
214
231
  fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
@@ -248,7 +265,7 @@ def cmd_config(args: argparse.Namespace) -> int:
248
265
  return _cmd_config_set(args)
249
266
  if action == "unset":
250
267
  return _cmd_config_unset(args)
251
- c.eprint(f"cctally config: unknown action {action!r}")
268
+ eprint(f"cctally config: unknown action {action!r}")
252
269
  return 2
253
270
 
254
271
 
@@ -265,7 +282,7 @@ def _config_known_value(config: dict, key: str) -> "object":
265
282
  if key == "display.tz":
266
283
  return c.get_display_tz_pref(config)
267
284
  if key == "alerts.enabled":
268
- return bool(c._get_alerts_config(config)["enabled"])
285
+ return bool(_get_alerts_config(config)["enabled"])
269
286
  if key == "dashboard.bind":
270
287
  # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
271
288
  # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
@@ -306,10 +323,9 @@ def _config_known_value(config: dict, key: str) -> "object":
306
323
 
307
324
 
308
325
  def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
309
- c = _cctally()
310
326
  key = args.key
311
327
  if key is not None and key not in ALLOWED_CONFIG_KEYS:
312
- c.eprint(f"cctally config: unknown config key {key!r}")
328
+ eprint(f"cctally config: unknown config key {key!r}")
313
329
  return 2
314
330
  pairs: "list[tuple[str, object]]" = []
315
331
  if key is None:
@@ -350,13 +366,13 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
350
366
  c = _cctally()
351
367
  key, raw = args.key, args.value
352
368
  if key not in ALLOWED_CONFIG_KEYS:
353
- c.eprint(f"cctally config: unknown config key {key!r}")
369
+ eprint(f"cctally config: unknown config key {key!r}")
354
370
  return 2
355
371
  if key == "display.tz":
356
372
  try:
357
- canonical = c.normalize_display_tz_value(raw)
373
+ canonical = normalize_display_tz_value(raw)
358
374
  except ValueError:
359
- c.eprint(f"cctally config: invalid IANA zone {raw!r}")
375
+ eprint(f"cctally config: invalid IANA zone {raw!r}")
360
376
  return 2
361
377
  with config_writer_lock():
362
378
  config = _load_config_unlocked()
@@ -399,8 +415,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
399
415
  # Validate the would-be merged block before persisting so
400
416
  # we never write a config that fails subsequent reads.
401
417
  try:
402
- c._get_alerts_config({**config, "alerts": alerts_block})
403
- except c._AlertsConfigError as exc:
418
+ _get_alerts_config({**config, "alerts": alerts_block})
419
+ except _AlertsConfigError as exc:
404
420
  print(f"cctally: alerts config error: {exc}", file=sys.stderr)
405
421
  return 2
406
422
  config["alerts"] = alerts_block
@@ -489,10 +505,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
489
505
 
490
506
 
491
507
  def _cmd_config_unset(args: argparse.Namespace) -> int:
492
- c = _cctally()
493
508
  key = args.key
494
509
  if key not in ALLOWED_CONFIG_KEYS:
495
- c.eprint(f"cctally config: unknown config key {key!r}")
510
+ eprint(f"cctally config: unknown config key {key!r}")
496
511
  return 2
497
512
  if key == "display.tz":
498
513
  with config_writer_lock():