cctally 1.44.2 → 1.44.3

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,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.44.3] - 2026-06-15
9
+
10
+ ### Fixed
11
+ - `blocks` and the dashboard Blocks panel no longer split a single 5-hour window into two overlapping blocks when Anthropic's `rate_limits.5h.resets_at` jitters by one second across a 10-minute boundary (e.g. `20:39:59` vs `20:40:00`). The recorded-window loader identified each window by re-flooring the raw, jittery reset string to 10-minute buckets, so a 1-second straddle landed in two different buckets and rendered as two adjacent ~5h rows (with the bulk of the cost in the spurious *earlier* row, e.g. a phantom `6:30 p.m.` block alongside the real `6:40 p.m.` one); the weighted non-overlap scheduler then kept the phantom because its heavy canonical neighbor outweighed the real chain by a single point, and the #116 force-restore added the real anchor back without evicting the phantom. The loader now keys each window by the canonical, jitter-collapsed `five_hour_window_key` the `record-usage` path already stores — so a straddle collapses to one bucket and both the CLI and the dashboard show a single block with the correct total. The committed `five_hour_blocks` rollup was always correct; only this display path was affected, and it self-corrects on the next read with no migration (#201).
12
+ - **Internal (test infra, no user-facing change): the per-migration golden byte-idempotency guard (#197) now passes on the public Linux CI matrix.** That guard compared each rebuilt golden to the committed `.sqlite` byte-for-byte, normalizing only the 4-byte writer-version header — but SQLite's whole on-disk page layout (page allocation / freelist / B-tree balancing) is not portable across library versions, and FTS5 stores its inverted index as version-dependent binary segments, so the goldens (generated on the maintainer's macOS SQLite 3.53.2) byte-mismatched the public Linux CI's SQLite 3.45.1 even though their logical content is identical. v1.44.2 was the guard's first exposure to a non-macOS SQLite (the matrix only runs on tag push / weekly cron / dispatch), so it went red. The guard is now two-tier: STRICT byte-idempotency on the goldens' origin SQLite version (the maintainer's machine, the full #197 guarantee that a regen won't dirty the committed file), falling back to a version-portable SEMANTIC fingerprint — `user_version` / `application_id` plus the canonical SQL dump of every real table, taken from an in-memory copy with the FTS5 virtual tables dropped — on any other SQLite version. Still catches real builder/data drift (verified non-vacuous); nothing to do on upgrade (#199).
13
+
8
14
  ## [1.44.2] - 2026-06-14
9
15
 
10
16
  ### Fixed
@@ -307,7 +307,7 @@ def _load_recorded_five_hour_windows(
307
307
  try:
308
308
  with open_db() as conn:
309
309
  rows = conn.execute(
310
- "SELECT five_hour_resets_at "
310
+ "SELECT five_hour_resets_at, five_hour_window_key "
311
311
  "FROM weekly_usage_snapshots "
312
312
  "WHERE five_hour_resets_at IS NOT NULL "
313
313
  " AND five_hour_resets_at >= ? "
@@ -326,7 +326,8 @@ def _load_recorded_five_hour_windows(
326
326
  canonical_rows: list[Any] = []
327
327
  try:
328
328
  canonical_rows = conn.execute(
329
- "SELECT five_hour_resets_at, block_start_at "
329
+ "SELECT five_hour_resets_at, block_start_at, "
330
+ " five_hour_window_key "
330
331
  "FROM five_hour_blocks "
331
332
  "WHERE five_hour_resets_at IS NOT NULL "
332
333
  " AND five_hour_resets_at >= ? "
@@ -378,9 +379,25 @@ def _load_recorded_five_hour_windows(
378
379
  # denied on parent dir) that propagate from open_db() before any
379
380
  # SQL runs. Either way, fall back to the heuristic anchor path.
380
381
  return [], {}, {}
382
+ # issue #201: identify each 5h window by its canonical, jitter-collapsed
383
+ # ``five_hour_window_key`` (the 10-min-floored epoch the record path
384
+ # already stored via the anchored ``_canonical_5h_window_key`` reuse)
385
+ # instead of re-flooring the raw ``five_hour_resets_at`` string. A
386
+ # 1-second reset jitter straddling a 10-minute floor boundary
387
+ # (``20:39:59`` vs ``20:40:00``) floors to two different buckets and
388
+ # would otherwise fork one physical window into two overlapping blocks
389
+ # — the exact split this column exists to prevent. Falls back to the
390
+ # pure floor for legacy rows whose key wasn't backfilled (``open_db``
391
+ # backfills NULL keys before this query runs, so this is defensive).
392
+ def _bucket_dt(window_key: Any, resets_dt: dt.datetime) -> dt.datetime:
393
+ if window_key is not None:
394
+ return dt.datetime.fromtimestamp(int(window_key), dt.timezone.utc)
395
+ return _c._floor_to_ten_minutes(resets_dt)
396
+
381
397
  counts: dict[dt.datetime, int] = {}
382
398
  for row in rows:
383
399
  raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
400
+ wkey = row["five_hour_window_key"] if hasattr(row, "keys") else row[1]
384
401
  if raw is None:
385
402
  continue
386
403
  try:
@@ -391,7 +408,7 @@ def _load_recorded_five_hour_windows(
391
408
  d = d.replace(tzinfo=dt.timezone.utc)
392
409
  else:
393
410
  d = d.astimezone(dt.timezone.utc)
394
- snapped = _c._floor_to_ten_minutes(d)
411
+ snapped = _bucket_dt(wkey, d)
395
412
  counts[snapped] = counts.get(snapped, 0) + 1
396
413
  # Overlay canonical rollup anchors at heavy weight. Same flooring
397
414
  # rule so a jittered raw value (e.g. 17:48Z) and its canonicalized
@@ -411,10 +428,16 @@ def _load_recorded_five_hour_windows(
411
428
  # truncated R keeps both blocks visible — without this fix the
412
429
  # earlier block's entries are silently rendered as a phantom
413
430
  # heuristic "~" row by `_group_entries_into_blocks`.
414
- canonical_pairs: list[tuple[dt.datetime, dt.datetime]] = []
431
+ # issue #201: each triple is ``(wkey_dt, bs, rs)`` — ``wkey_dt`` is the
432
+ # canonical window_key (decoded to a UTC datetime) and is the SAME
433
+ # bucket identity used for the raw-snapshot counts above, so the heavy
434
+ # canonical overlay always lands in the same bucket as its supporting
435
+ # raw rows (never a jitter-split sibling bucket).
436
+ canonical_pairs: list[tuple[dt.datetime, dt.datetime, dt.datetime]] = []
415
437
  for row in canonical_rows:
416
438
  rs_raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
417
439
  bs_raw = row["block_start_at"] if hasattr(row, "keys") else row[1]
440
+ wkey = row["five_hour_window_key"] if hasattr(row, "keys") else row[2]
418
441
  if rs_raw is None or bs_raw is None:
419
442
  continue
420
443
  try:
@@ -430,8 +453,8 @@ def _load_recorded_five_hour_windows(
430
453
  bs = bs.replace(tzinfo=dt.timezone.utc)
431
454
  else:
432
455
  bs = bs.astimezone(dt.timezone.utc)
433
- canonical_pairs.append((bs, rs))
434
- canonical_pairs.sort(key=lambda p: p[0])
456
+ canonical_pairs.append((_bucket_dt(wkey, rs), bs, rs))
457
+ canonical_pairs.sort(key=lambda p: p[1])
435
458
 
436
459
  # issue #76: canonical_intervals maps every floored R -> its EXACT
437
460
  # (block_start_at, five_hour_resets_at) — both UTC, rs un-floored
@@ -444,9 +467,8 @@ def _load_recorded_five_hour_windows(
444
467
  canonical_intervals: dict[
445
468
  dt.datetime, tuple[dt.datetime, dt.datetime]
446
469
  ] = {}
447
- for bs, rs in canonical_pairs:
448
- snapped = _c._floor_to_ten_minutes(rs)
449
- canonical_intervals[snapped] = (bs, rs)
470
+ for wkey_dt, bs, rs in canonical_pairs:
471
+ canonical_intervals[wkey_dt] = (bs, rs)
450
472
 
451
473
  # Detect overlap-with-credit and replace the earlier R with a
452
474
  # credit-truncated anchor. The (anchor → real_block_start) map is
@@ -454,11 +476,12 @@ def _load_recorded_five_hour_windows(
454
476
  # real block_start_at on the display row (instead of the default
455
477
  # R - 5h, which would be hours earlier for a 2h-truncated block).
456
478
  block_start_overrides: dict[dt.datetime, dt.datetime] = {}
457
- truncated_pairs: list[tuple[dt.datetime, dt.datetime]] = []
458
- for i, (bs, rs) in enumerate(canonical_pairs):
479
+ truncated_pairs: list[tuple[dt.datetime, dt.datetime, dt.datetime]] = []
480
+ for i, (wkey_dt, bs, rs) in enumerate(canonical_pairs):
481
+ anchor_key = wkey_dt # canonical window_key identity (issue #201)
459
482
  truncated_R = rs
460
483
  if i + 1 < len(canonical_pairs):
461
- next_bs, _next_rs = canonical_pairs[i + 1]
484
+ _next_wk, next_bs, _next_rs = canonical_pairs[i + 1]
462
485
  if rs > next_bs: # overlap with next block
463
486
  # Look for a credit moment inside [next_bs, rs] — the
464
487
  # part of the earlier block that overlaps the next.
@@ -471,24 +494,24 @@ def _load_recorded_five_hour_windows(
471
494
  # drop one via its weight-tiebreaker.
472
495
  if bs < cm_floored < rs:
473
496
  truncated_R = cm_floored
497
+ anchor_key = cm_floored
474
498
  block_start_overrides[cm_floored] = bs
475
- # Rewrite canonical_intervals[snapped_orig]
476
- # to the truncated interval under the
477
- # truncated key. issue #76: the
478
- # partitioner reads canonical_intervals
479
- # for the exact bs/rs; the truncated entry
480
- # must reflect the credit-shifted upper
481
- # bound (cm_floored) AND the real bs (the
482
- # override) so partition + Phase 1.5
483
- # render the credit-shortened block
484
- # consistently.
485
- snapped_orig = _c._floor_to_ten_minutes(rs)
486
- canonical_intervals.pop(snapped_orig, None)
499
+ # Rewrite canonical_intervals under the
500
+ # truncated key. issue #76: the partitioner
501
+ # reads canonical_intervals for the exact
502
+ # bs/rs; the truncated entry must reflect the
503
+ # credit-shifted upper bound (cm_floored) AND
504
+ # the real bs (the override) so partition +
505
+ # Phase 1.5 render the credit-shortened block
506
+ # consistently. issue #201: the original
507
+ # entry is keyed by the canonical window_key,
508
+ # so pop under ``wkey_dt`` (not floor(rs)).
509
+ canonical_intervals.pop(wkey_dt, None)
487
510
  canonical_intervals[cm_floored] = (
488
511
  bs, cm_floored,
489
512
  )
490
513
  break
491
- truncated_pairs.append((bs, truncated_R))
514
+ truncated_pairs.append((anchor_key, bs, truncated_R))
492
515
 
493
516
  # Truncated anchors are credit-adjusted and known-good; bypass the
494
517
  # `_select_non_overlapping_recorded_windows` weighted scheduler for
@@ -501,16 +524,16 @@ def _load_recorded_five_hour_windows(
501
524
  # input weight (so jittered same-bucket raw values still collapse)
502
525
  # but skip them when computing the overlap-safe subset.
503
526
  truncated_anchors: set[dt.datetime] = set()
504
- for bs, rs in truncated_pairs:
505
- snapped = _c._floor_to_ten_minutes(rs)
506
- if rs != _c._floor_to_ten_minutes(rs):
507
- if rs in block_start_overrides:
508
- block_start_overrides[snapped] = block_start_overrides.pop(rs)
509
- # Identify truncated anchors by membership in the override map
510
- # (only credit-truncated entries land there).
511
- if snapped in block_start_overrides:
512
- truncated_anchors.add(snapped)
513
- counts[snapped] = counts.get(snapped, 0) + _CANONICAL_WEIGHT_THRESHOLD
527
+ for anchor_key, _bs, _rs in truncated_pairs:
528
+ # ``anchor_key`` is already a floored canonical key — the window_key
529
+ # for an untruncated anchor (issue #201) or the credit-floored key
530
+ # for a truncated one — and the override map is keyed by it
531
+ # directly, so the legacy floor-relocation dance is no longer
532
+ # needed. Identify truncated anchors by membership in the override
533
+ # map (only credit-truncated entries land there).
534
+ if anchor_key in block_start_overrides:
535
+ truncated_anchors.add(anchor_key)
536
+ counts[anchor_key] = counts.get(anchor_key, 0) + _CANONICAL_WEIGHT_THRESHOLD
514
537
 
515
538
  non_truncated_items = [
516
539
  (a, w) for a, w in counts.items() if a not in truncated_anchors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.44.2",
3
+ "version": "1.44.3",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {