cctally 1.2.0 → 1.4.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 +41 -0
- package/README.md +8 -1
- package/bin/cctally +2544 -132
- package/dashboard/static/assets/{index-BIql6NB3.js → index-BQfozCcN.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +10 -2
package/bin/cctally
CHANGED
|
@@ -83,7 +83,17 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
|
83
83
|
|
|
84
84
|
# === Release automation (issue #24) ===
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
_CHANGELOG_OVERRIDE = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
|
|
87
|
+
if _CHANGELOG_OVERRIDE:
|
|
88
|
+
# Fixture-stability hook for `bin/cctally-share-test` — points
|
|
89
|
+
# `_share_resolve_version()` (and the broader release machinery) at a
|
|
90
|
+
# per-scenario CHANGELOG so version stamping in goldens stays
|
|
91
|
+
# deterministic regardless of the in-tree CHANGELOG state. Mirrors the
|
|
92
|
+
# `CCTALLY_AS_OF` env-only precedent: not in --help, no docstring
|
|
93
|
+
# surface; consumed exclusively by harness wrappers.
|
|
94
|
+
CHANGELOG_PATH = pathlib.Path(_CHANGELOG_OVERRIDE)
|
|
95
|
+
else:
|
|
96
|
+
CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
87
97
|
|
|
88
98
|
|
|
89
99
|
def _package_json_path() -> pathlib.Path:
|
|
@@ -1446,79 +1456,82 @@ def _release_run_phase_gh(version: str, body: str) -> int:
|
|
|
1446
1456
|
return 0
|
|
1447
1457
|
|
|
1448
1458
|
|
|
1459
|
+
_RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT = 300.0
|
|
1460
|
+
_RELEASE_NPM_POLL_INTERVAL_S_DEFAULT = 10.0
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _release_npm_poll_timing() -> tuple[float, float]:
|
|
1464
|
+
"""Return (timeout_s, interval_s) honoring env-hook overrides.
|
|
1465
|
+
|
|
1466
|
+
Hidden env hooks (mirrors ``CCTALLY_RELEASE_DATE_UTC``):
|
|
1467
|
+
- CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S
|
|
1468
|
+
- CCTALLY_RELEASE_NPM_POLL_INTERVAL_S
|
|
1469
|
+
Used by the harness (and pytest) to make Phase 5 fixtures deterministic.
|
|
1470
|
+
Not in --help.
|
|
1471
|
+
"""
|
|
1472
|
+
def _f(name: str, default: float) -> float:
|
|
1473
|
+
try:
|
|
1474
|
+
return float(os.environ[name])
|
|
1475
|
+
except (KeyError, ValueError):
|
|
1476
|
+
return default
|
|
1477
|
+
return (
|
|
1478
|
+
_f("CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S", _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT),
|
|
1479
|
+
_f("CCTALLY_RELEASE_NPM_POLL_INTERVAL_S", _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT),
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1449
1483
|
def _release_run_phase_npm(
|
|
1450
1484
|
version: str,
|
|
1451
1485
|
public_clone: pathlib.Path,
|
|
1452
1486
|
*,
|
|
1453
1487
|
dist_tag: str,
|
|
1454
1488
|
) -> int:
|
|
1455
|
-
"""Phase 5 —
|
|
1489
|
+
"""Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
|
|
1456
1490
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
``
|
|
1491
|
+
Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
|
|
1492
|
+
``.github/workflows/release-npm.yml`` fires on tag-push and runs
|
|
1493
|
+
``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
|
|
1494
|
+
no operator 2FA round-trip — fixes the passkey-in-subprocess failure
|
|
1495
|
+
mode where npm 2FA blocks ``npm publish`` from a non-interactive
|
|
1496
|
+
subprocess).
|
|
1460
1497
|
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
perspective; npm is the third channel and treated as polish.
|
|
1498
|
+
Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
|
|
1499
|
+
it appears, with timeout. ``cctally`` never invokes ``npm publish``
|
|
1500
|
+
locally anymore — Trusted Publisher binds the right to publish to the
|
|
1501
|
+
public-repo workflow, not to the operator's `npm login` token.
|
|
1466
1502
|
|
|
1467
|
-
Returns:
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1503
|
+
Returns ``0`` on observed success OR poll-timeout (soft-success: phases
|
|
1504
|
+
1-4 landed; the workflow is either succeeding or visibly failing on
|
|
1505
|
+
github.com; ``--resume`` re-checks the registry).
|
|
1506
|
+
|
|
1507
|
+
Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
|
|
1508
|
+
``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
|
|
1472
1509
|
"""
|
|
1473
|
-
print(f"phase 5: npm publish (tag={dist_tag})")
|
|
1510
|
+
print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
|
|
1474
1511
|
if _release_phase_npm_done(version):
|
|
1475
1512
|
print(f" cctally@{version} already on npm — skipping.")
|
|
1476
1513
|
return 0
|
|
1477
1514
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
print(
|
|
1499
|
-
f"\n npm not authenticated. After `npm login`, run:\n"
|
|
1500
|
-
f" cd {public_clone} && npm publish --tag {dist_tag}\n",
|
|
1501
|
-
file=sys.stderr,
|
|
1502
|
-
)
|
|
1503
|
-
return 0
|
|
1504
|
-
|
|
1505
|
-
pub = subprocess.run(
|
|
1506
|
-
["npm", "publish", "--tag", dist_tag],
|
|
1507
|
-
cwd=str(public_clone),
|
|
1508
|
-
check=False,
|
|
1509
|
-
)
|
|
1510
|
-
if pub.returncode != 0:
|
|
1511
|
-
return 3
|
|
1512
|
-
|
|
1513
|
-
if not _release_phase_npm_done(version):
|
|
1514
|
-
print(
|
|
1515
|
-
f" warning: `npm publish` returned 0 but `npm view "
|
|
1516
|
-
f"cctally@{version}` doesn't see the version yet. Registry "
|
|
1517
|
-
f"propagation lag is normal; check `npm view cctally version` "
|
|
1518
|
-
f"in 30s.",
|
|
1519
|
-
file=sys.stderr,
|
|
1520
|
-
)
|
|
1521
|
-
return 0
|
|
1515
|
+
timeout_s, interval_s = _release_npm_poll_timing()
|
|
1516
|
+
deadline = time.monotonic() + timeout_s
|
|
1517
|
+
while True:
|
|
1518
|
+
if _release_phase_npm_done(version):
|
|
1519
|
+
print(f" cctally@{version} on npm registry ✓")
|
|
1520
|
+
return 0
|
|
1521
|
+
if time.monotonic() >= deadline:
|
|
1522
|
+
print(
|
|
1523
|
+
f"\n timed out after {timeout_s:.0f}s waiting for "
|
|
1524
|
+
f"cctally@{version} on npm. The GHA workflow may still be "
|
|
1525
|
+
f"running or have failed — check:\n"
|
|
1526
|
+
f" https://github.com/{PUBLIC_REPO}/actions\n"
|
|
1527
|
+
f" Re-run `cctally release --resume` once the workflow "
|
|
1528
|
+
f"completes, or for emergency manual publish:\n"
|
|
1529
|
+
f" cd {public_clone} && npm publish --access public "
|
|
1530
|
+
f"--tag {dist_tag}\n",
|
|
1531
|
+
file=sys.stderr,
|
|
1532
|
+
)
|
|
1533
|
+
return 0
|
|
1534
|
+
time.sleep(interval_s)
|
|
1522
1535
|
|
|
1523
1536
|
|
|
1524
1537
|
def _release_run_phase_brew(
|
|
@@ -1538,8 +1551,8 @@ def _release_run_phase_brew(
|
|
|
1538
1551
|
version (idempotency under ``--resume``).
|
|
1539
1552
|
- Dirty working tree — refuses with exit 2 and points the operator
|
|
1540
1553
|
at ``--resume``.
|
|
1541
|
-
- Push failure — auth-fallback parity with
|
|
1542
|
-
|
|
1554
|
+
- Push failure — auth-fallback parity with Phase 4: prints the
|
|
1555
|
+
exact recovery command and returns 0. Phases 1-5 already
|
|
1543
1556
|
succeeded, the release IS published from the user's
|
|
1544
1557
|
perspective; the brew tap is the third channel and treated as
|
|
1545
1558
|
polish.
|
|
@@ -1620,8 +1633,40 @@ def _release_run_phase_brew(
|
|
|
1620
1633
|
)
|
|
1621
1634
|
# Tag is best-effort — re-running after a partial publish should
|
|
1622
1635
|
# not fail just because the tag already exists locally.
|
|
1636
|
+
#
|
|
1637
|
+
# Annotated form with `-m` (issue #25): plain `git tag <name>` is
|
|
1638
|
+
# silently upgraded to `git tag -s <name>` under operator-global
|
|
1639
|
+
# `tag.gpgsign=true` and demands a message via editor. The release
|
|
1640
|
+
# script has no editor stdin, so git aborts with `fatal: no tag
|
|
1641
|
+
# message?` — the atomic push refspec then fails with "src refspec
|
|
1642
|
+
# does not match any" because the local tag was never created, and
|
|
1643
|
+
# the auth-fallback branch below silently swallows the failure as
|
|
1644
|
+
# exit 0. Mirrors Phase 2's signing detection (signing_key +
|
|
1645
|
+
# tag.gpgsign → -s, else fall back), with one defensive divergence:
|
|
1646
|
+
# the fallback uses --no-sign (not bare -a) so the tag still lands
|
|
1647
|
+
# under tag.gpgsign=true without a usable signing key configured.
|
|
1648
|
+
# Brew install reads the formula off the tap's default branch, not
|
|
1649
|
+
# the tag, so signing the tap tag is operationally moot — it exists
|
|
1650
|
+
# for history bookkeeping and atomic-push transport.
|
|
1651
|
+
signing_key = subprocess.run(
|
|
1652
|
+
["git", "-C", str(brew_clone), "config", "--get", "user.signingkey"],
|
|
1653
|
+
capture_output=True,
|
|
1654
|
+
text=True,
|
|
1655
|
+
).stdout.strip()
|
|
1656
|
+
tag_gpgsign = (
|
|
1657
|
+
subprocess.run(
|
|
1658
|
+
["git", "-C", str(brew_clone), "config", "--get", "tag.gpgsign"],
|
|
1659
|
+
capture_output=True,
|
|
1660
|
+
text=True,
|
|
1661
|
+
)
|
|
1662
|
+
.stdout.strip()
|
|
1663
|
+
.lower()
|
|
1664
|
+
== "true"
|
|
1665
|
+
)
|
|
1666
|
+
sign_flag = "-s" if (signing_key and tag_gpgsign) else "--no-sign"
|
|
1623
1667
|
subprocess.run(
|
|
1624
|
-
["git", "-C", str(brew_clone), "tag",
|
|
1668
|
+
["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
|
|
1669
|
+
f"cctally v{version}", f"v{version}"],
|
|
1625
1670
|
check=False,
|
|
1626
1671
|
)
|
|
1627
1672
|
# Single ATOMIC push of branch + tag in one transaction — the
|
|
@@ -1630,22 +1675,29 @@ def _release_run_phase_brew(
|
|
|
1630
1675
|
# split branch-push + tag-push pair admits: a tag landing without
|
|
1631
1676
|
# the branch landing would leave `brew install` serving the OLD
|
|
1632
1677
|
# formula off the tap's default branch even though the remote
|
|
1633
|
-
# carries the new tag. Tag refspec is explicit (`src:dst`)
|
|
1634
|
-
#
|
|
1635
|
-
#
|
|
1678
|
+
# carries the new tag. Tag refspec is explicit (`src:dst`) for
|
|
1679
|
+
# atomic-push semantics — `--atomic` requires named refs, not the
|
|
1680
|
+
# implicit `--follow-tags` path.
|
|
1636
1681
|
push = subprocess.run(
|
|
1637
1682
|
["git", "-C", str(brew_clone), "push", "--atomic", "origin",
|
|
1638
1683
|
"HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
|
|
1639
1684
|
check=False,
|
|
1640
1685
|
)
|
|
1641
1686
|
if push.returncode != 0:
|
|
1687
|
+
# If the local tag isn't there (e.g., the operator hit the
|
|
1688
|
+
# tag.gpgsign edge case from issue #25 even with the fix), the
|
|
1689
|
+
# plain push refspec fails. Surface a tag-create fallback in
|
|
1690
|
+
# the hint so copy-paste recovery is self-contained.
|
|
1642
1691
|
print(
|
|
1643
1692
|
f"\n push failed. Manual recovery:\n"
|
|
1693
|
+
f" # If local tag v{version} is missing (e.g., gpgsign issue):\n"
|
|
1694
|
+
f" git -C {brew_clone} tag --no-sign -a -m \"cctally v{version}\" v{version}\n"
|
|
1695
|
+
f" # Then push:\n"
|
|
1644
1696
|
f" git -C {brew_clone} push --atomic origin HEAD "
|
|
1645
1697
|
f"refs/tags/v{version}:refs/tags/v{version}\n",
|
|
1646
1698
|
file=sys.stderr,
|
|
1647
1699
|
)
|
|
1648
|
-
return 0 # auth-fallback semantics;
|
|
1700
|
+
return 0 # auth-fallback semantics; parity with Phase 4.
|
|
1649
1701
|
|
|
1650
1702
|
return 0
|
|
1651
1703
|
|
|
@@ -1846,10 +1898,12 @@ def cmd_release(args: argparse.Namespace) -> int:
|
|
|
1846
1898
|
is_prerelease = "-" in next_v
|
|
1847
1899
|
dist_tag = "next" if is_prerelease else "latest"
|
|
1848
1900
|
|
|
1849
|
-
# Phase 5 — npm publish
|
|
1850
|
-
#
|
|
1851
|
-
#
|
|
1852
|
-
#
|
|
1901
|
+
# Phase 5 — await npm publish via the public-repo GHA workflow
|
|
1902
|
+
# (release-npm.yml), which fires on the tag pushed in Phase 3. Phase 5
|
|
1903
|
+
# here is observation-only (poll `npm view` with timeout); poll-timeout
|
|
1904
|
+
# returns 0 — the workflow runs independently on github.com, and
|
|
1905
|
+
# `--resume` re-checks the registry. `--skip-npm` is the operator
|
|
1906
|
+
# escape hatch for ad-hoc cuts.
|
|
1853
1907
|
if args.skip_npm:
|
|
1854
1908
|
print("phase 5: npm skipped (--skip-npm)")
|
|
1855
1909
|
else:
|
|
@@ -1973,8 +2027,8 @@ def _release_dry_run(
|
|
|
1973
2027
|
else:
|
|
1974
2028
|
dist_tag = "next" if "-" in next_v else "latest"
|
|
1975
2029
|
print(
|
|
1976
|
-
f"Would
|
|
1977
|
-
f"
|
|
2030
|
+
f"Would await: cctally@{next_v} on npmjs.org via GHA workflow "
|
|
2031
|
+
f"(release-npm.yml in public repo; tag={dist_tag})"
|
|
1978
2032
|
)
|
|
1979
2033
|
print()
|
|
1980
2034
|
# Phase 6 — brew formula bump plan.
|
|
@@ -8185,6 +8239,41 @@ def _render_claude_session_table(
|
|
|
8185
8239
|
return "\n".join(out)
|
|
8186
8240
|
|
|
8187
8241
|
|
|
8242
|
+
def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
|
|
8243
|
+
"""Return ``{row_index: disambiguated_label}`` for project rows whose
|
|
8244
|
+
bare ``display_key`` collides with another row's basename.
|
|
8245
|
+
|
|
8246
|
+
When two projects share a basename (e.g., two ``app`` directories under
|
|
8247
|
+
different parents), suffix the colliding rows with the parent-directory
|
|
8248
|
+
segment ("(work)" / "(personal)") so they remain visually and
|
|
8249
|
+
semantically distinct. Prefer ``key.git_root`` as the disambiguation
|
|
8250
|
+
source when present; fall back to ``key.bucket_path`` for no-git rows.
|
|
8251
|
+
|
|
8252
|
+
Used by:
|
|
8253
|
+
- ``_render_project_table`` (terminal table render).
|
|
8254
|
+
- ``_build_project_snapshot`` (share artifact table + chart) — without
|
|
8255
|
+
this, two same-basename projects collapse to a single anonymous
|
|
8256
|
+
``project-N`` after scrub, losing rank meaning AND uniqueness.
|
|
8257
|
+
|
|
8258
|
+
Rows that do not collide are absent from the returned dict; callers
|
|
8259
|
+
fall back to ``key.display_key`` for those.
|
|
8260
|
+
"""
|
|
8261
|
+
display_counts: dict[str, int] = {}
|
|
8262
|
+
for r in rows:
|
|
8263
|
+
dk = r["key"].display_key
|
|
8264
|
+
display_counts[dk] = display_counts.get(dk, 0) + 1
|
|
8265
|
+
augmented: dict[int, str] = {}
|
|
8266
|
+
for idx, r in enumerate(rows):
|
|
8267
|
+
if display_counts[r["key"].display_key] > 1:
|
|
8268
|
+
source_path = r["key"].git_root or r["key"].bucket_path
|
|
8269
|
+
if source_path:
|
|
8270
|
+
parent = (
|
|
8271
|
+
os.path.basename(os.path.dirname(source_path)) or "/"
|
|
8272
|
+
)
|
|
8273
|
+
augmented[idx] = f"{r['key'].display_key} ({parent})"
|
|
8274
|
+
return augmented
|
|
8275
|
+
|
|
8276
|
+
|
|
8188
8277
|
def _render_project_table(
|
|
8189
8278
|
rows: list[dict],
|
|
8190
8279
|
*,
|
|
@@ -8243,22 +8332,11 @@ def _render_project_table(
|
|
|
8243
8332
|
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
8244
8333
|
return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
|
|
8245
8334
|
|
|
8246
|
-
# Basename-collision disambiguation:
|
|
8247
|
-
#
|
|
8248
|
-
#
|
|
8249
|
-
#
|
|
8250
|
-
|
|
8251
|
-
display_counts: dict[str, int] = {}
|
|
8252
|
-
for r in rows:
|
|
8253
|
-
dk = r["key"].display_key
|
|
8254
|
-
display_counts[dk] = display_counts.get(dk, 0) + 1
|
|
8255
|
-
augmented: dict[int, str] = {}
|
|
8256
|
-
for idx, r in enumerate(rows):
|
|
8257
|
-
if display_counts[r["key"].display_key] > 1:
|
|
8258
|
-
source_path = r["key"].git_root or r["key"].bucket_path
|
|
8259
|
-
if source_path:
|
|
8260
|
-
parent = os.path.basename(os.path.dirname(source_path)) or "/"
|
|
8261
|
-
augmented[idx] = f"{r['key'].display_key} ({parent})"
|
|
8335
|
+
# Basename-collision disambiguation: hoisted to a module-level helper
|
|
8336
|
+
# so the share-snapshot builder can reuse the same logic (without it,
|
|
8337
|
+
# two same-basename projects collapse to a single anonymous `project-N`
|
|
8338
|
+
# after scrub, breaking both privacy uniqueness AND chart rank meaning).
|
|
8339
|
+
augmented = _project_disambiguate_labels(rows)
|
|
8262
8340
|
|
|
8263
8341
|
def _project_cell(idx: int, r: dict) -> tuple[str, Any]:
|
|
8264
8342
|
"""Return (plain_text, color_fn_or_None) for the Project cell.
|
|
@@ -12278,12 +12356,36 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
|
|
|
12278
12356
|
)
|
|
12279
12357
|
pending_alerts.append(payload)
|
|
12280
12358
|
|
|
12281
|
-
# ──
|
|
12282
|
-
# Self-healing sweep
|
|
12283
|
-
#
|
|
12284
|
-
#
|
|
12285
|
-
#
|
|
12286
|
-
#
|
|
12359
|
+
# ── Reset-crossing cross-flag (opportunistic, JOIN-based) ──
|
|
12360
|
+
# Self-healing sweep: every tick, flag any open block whose
|
|
12361
|
+
# [block_start_at, last_observed_at_utc] interval crosses a
|
|
12362
|
+
# weekly reset, from either of two sources:
|
|
12363
|
+
# (a) week_reset_events — Anthropic-shifted MID-week resets
|
|
12364
|
+
# (prior week_end_at was still in the future at detect
|
|
12365
|
+
# time; see cmd_record_usage's reset-event detection).
|
|
12366
|
+
# (b) weekly_usage_snapshots.week_start_at — NATURAL weekly
|
|
12367
|
+
# boundaries. These never get a week_reset_events row
|
|
12368
|
+
# (mid-week detection requires the prior end to be in
|
|
12369
|
+
# the future), so source (a) silently misses blocks
|
|
12370
|
+
# that span a routine week reset. Without this clause
|
|
12371
|
+
# the dashboard's "Δ pp this block" delta is computed
|
|
12372
|
+
# against the pre-reset 7d% (~94%) versus post-reset
|
|
12373
|
+
# (~0%) and renders as a misleading −94pp drop.
|
|
12374
|
+
# Predicate (b) uses strict ``>`` on the lower bound so a
|
|
12375
|
+
# block that starts EXACTLY at the boundary (post-reset) is
|
|
12376
|
+
# not flagged. Symmetric with the historical-backfill
|
|
12377
|
+
# predicate (§4.2 step 5). Idempotent (only flips 0 → 1).
|
|
12378
|
+
#
|
|
12379
|
+
# Comparisons go through ``unixepoch()`` rather than a raw
|
|
12380
|
+
# lex BETWEEN: ``parse_iso_datetime`` returns host-local
|
|
12381
|
+
# tz-aware datetimes (line 9433: ``return parsed.astimezone()``),
|
|
12382
|
+
# so ``block_start_at`` is stored with the host's display
|
|
12383
|
+
# offset (e.g. ``+03:00``) while ``week_start_at`` is
|
|
12384
|
+
# ``+00:00`` and ``last_observed_at_utc`` is ``Z``. A lex
|
|
12385
|
+
# compare across mixed offsets silently mis-orders moments
|
|
12386
|
+
# for non-UTC hosts; ``unixepoch()`` normalizes all three
|
|
12387
|
+
# to seconds-since-epoch and is correct regardless of
|
|
12388
|
+
# offset suffix.
|
|
12287
12389
|
#
|
|
12288
12390
|
# Why the JOIN rather than a per-tick param: an earlier
|
|
12289
12391
|
# design passed mid_week_reset_at only on the tick that
|
|
@@ -12298,13 +12400,21 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
|
|
|
12298
12400
|
UPDATE five_hour_blocks
|
|
12299
12401
|
SET crossed_seven_day_reset = 1
|
|
12300
12402
|
WHERE crossed_seven_day_reset = 0
|
|
12301
|
-
AND
|
|
12302
|
-
|
|
12303
|
-
FROM
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12403
|
+
AND (
|
|
12404
|
+
EXISTS (
|
|
12405
|
+
SELECT 1 FROM week_reset_events e
|
|
12406
|
+
WHERE unixepoch(e.effective_reset_at_utc)
|
|
12407
|
+
BETWEEN unixepoch(five_hour_blocks.block_start_at)
|
|
12408
|
+
AND unixepoch(five_hour_blocks.last_observed_at_utc)
|
|
12409
|
+
)
|
|
12410
|
+
OR EXISTS (
|
|
12411
|
+
SELECT 1 FROM weekly_usage_snapshots ws
|
|
12412
|
+
WHERE ws.week_start_at IS NOT NULL
|
|
12413
|
+
AND unixepoch(ws.week_start_at)
|
|
12414
|
+
> unixepoch(five_hour_blocks.block_start_at)
|
|
12415
|
+
AND unixepoch(ws.week_start_at)
|
|
12416
|
+
<= unixepoch(five_hour_blocks.last_observed_at_utc)
|
|
12417
|
+
)
|
|
12308
12418
|
)
|
|
12309
12419
|
""",
|
|
12310
12420
|
)
|
|
@@ -12414,16 +12524,36 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
|
|
|
12414
12524
|
last_obs_dt = parse_iso_datetime(last_obs, "last_obs backfill")
|
|
12415
12525
|
|
|
12416
12526
|
# Cross-reset detection (interval predicate, symmetric with
|
|
12417
|
-
# the live path's UPDATE in T4).
|
|
12527
|
+
# the live path's UPDATE in T4). Two sources:
|
|
12528
|
+
# (a) week_reset_events — Anthropic-shifted mid-week resets.
|
|
12529
|
+
# (b) weekly_usage_snapshots.week_start_at — natural week
|
|
12530
|
+
# boundaries (no event row for these).
|
|
12531
|
+
# Strict ``>`` on the lower bound for (b) so a block whose
|
|
12532
|
+
# block_start_at coincides with a week boundary is not flagged.
|
|
12533
|
+
# ``unixepoch()`` normalizes the comparison across mixed tz
|
|
12534
|
+
# suffixes (block_start_at is host-local; week_start_at /
|
|
12535
|
+
# effective_reset_at_utc are ``+00:00``); see the live-path
|
|
12536
|
+
# comment for rationale.
|
|
12418
12537
|
cross_row = conn.execute(
|
|
12419
12538
|
"""
|
|
12420
12539
|
SELECT 1 FROM week_reset_events
|
|
12421
|
-
WHERE effective_reset_at_utc >= ?
|
|
12422
|
-
AND effective_reset_at_utc <= ?
|
|
12540
|
+
WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
|
|
12541
|
+
AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
|
|
12423
12542
|
LIMIT 1
|
|
12424
12543
|
""",
|
|
12425
12544
|
(block_start_at, last_obs),
|
|
12426
12545
|
).fetchone()
|
|
12546
|
+
if cross_row is None:
|
|
12547
|
+
cross_row = conn.execute(
|
|
12548
|
+
"""
|
|
12549
|
+
SELECT 1 FROM weekly_usage_snapshots
|
|
12550
|
+
WHERE week_start_at IS NOT NULL
|
|
12551
|
+
AND unixepoch(week_start_at) > unixepoch(?)
|
|
12552
|
+
AND unixepoch(week_start_at) <= unixepoch(?)
|
|
12553
|
+
LIMIT 1
|
|
12554
|
+
""",
|
|
12555
|
+
(block_start_at, last_obs),
|
|
12556
|
+
).fetchone()
|
|
12427
12557
|
crossed = 1 if cross_row is not None else 0
|
|
12428
12558
|
|
|
12429
12559
|
# is_closed: 1 if the canonical reset moment is already past.
|
|
@@ -14203,6 +14333,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
14203
14333
|
|
|
14204
14334
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
14205
14335
|
"""Show usage report grouped by display-timezone date."""
|
|
14336
|
+
_share_validate_args(args)
|
|
14206
14337
|
config = load_config()
|
|
14207
14338
|
tz = resolve_display_tz(args, config)
|
|
14208
14339
|
args._resolved_tz = tz
|
|
@@ -14220,6 +14351,35 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
14220
14351
|
# Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
|
|
14221
14352
|
days = _aggregate_daily(all_entries, mode="auto", tz=tz)
|
|
14222
14353
|
|
|
14354
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
14355
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
14356
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
14357
|
+
# Gate runs BEFORE the `--order desc` reversal so the BarChart bars
|
|
14358
|
+
# render chronologically regardless of `--order`. Table rows in the
|
|
14359
|
+
# rendered artifact, however, must respect `--order desc` (parity
|
|
14360
|
+
# with terminal / JSON output) — handled by reversing snap.rows
|
|
14361
|
+
# post-build below; the chart points stay chronological because
|
|
14362
|
+
# they were built from ascending `days`.
|
|
14363
|
+
if getattr(args, "format", None):
|
|
14364
|
+
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
14365
|
+
# the headline daily-cost trend; per-model sub-rows aren't in the
|
|
14366
|
+
# share spec scope). Same convention applies to other share-enabled
|
|
14367
|
+
# subcommands (cmd_report's --detail, etc.).
|
|
14368
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
14369
|
+
snap = _build_daily_snapshot(
|
|
14370
|
+
days,
|
|
14371
|
+
period_start=range_start,
|
|
14372
|
+
period_end=range_end,
|
|
14373
|
+
display_tz=display_tz_str,
|
|
14374
|
+
version=_share_resolve_version(),
|
|
14375
|
+
theme=args.theme,
|
|
14376
|
+
reveal_projects=args.reveal_projects,
|
|
14377
|
+
)
|
|
14378
|
+
if args.order == "desc":
|
|
14379
|
+
snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
|
|
14380
|
+
_share_render_and_emit(snap, args)
|
|
14381
|
+
return 0
|
|
14382
|
+
|
|
14223
14383
|
# Apply sort order.
|
|
14224
14384
|
if args.order == "desc":
|
|
14225
14385
|
days = list(reversed(days))
|
|
@@ -14241,6 +14401,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
14241
14401
|
|
|
14242
14402
|
def cmd_monthly(args: argparse.Namespace) -> int:
|
|
14243
14403
|
"""Show usage report grouped by display-timezone calendar month."""
|
|
14404
|
+
_share_validate_args(args)
|
|
14244
14405
|
config = load_config()
|
|
14245
14406
|
tz = resolve_display_tz(args, config)
|
|
14246
14407
|
args._resolved_tz = tz
|
|
@@ -14256,6 +14417,32 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
14256
14417
|
|
|
14257
14418
|
months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
|
|
14258
14419
|
|
|
14420
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
14421
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
14422
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
14423
|
+
# Gate runs BEFORE the `--order desc` reversal so the BarChart bars
|
|
14424
|
+
# render chronologically regardless of `--order`. Table rows in the
|
|
14425
|
+
# rendered artifact respect `--order desc` (parity with terminal /
|
|
14426
|
+
# JSON) via post-build snap.rows reversal; chart stays chronological.
|
|
14427
|
+
if getattr(args, "format", None):
|
|
14428
|
+
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
14429
|
+
# the headline monthly-cost trend; per-model sub-rows aren't in the
|
|
14430
|
+
# share spec scope). Same convention as cmd_daily / cmd_report.
|
|
14431
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
14432
|
+
snap = _build_monthly_snapshot(
|
|
14433
|
+
months,
|
|
14434
|
+
period_start=range_start,
|
|
14435
|
+
period_end=range_end,
|
|
14436
|
+
display_tz=display_tz_str,
|
|
14437
|
+
version=_share_resolve_version(),
|
|
14438
|
+
theme=args.theme,
|
|
14439
|
+
reveal_projects=args.reveal_projects,
|
|
14440
|
+
)
|
|
14441
|
+
if args.order == "desc":
|
|
14442
|
+
snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
|
|
14443
|
+
_share_render_and_emit(snap, args)
|
|
14444
|
+
return 0
|
|
14445
|
+
|
|
14259
14446
|
if args.order == "desc":
|
|
14260
14447
|
months = list(reversed(months))
|
|
14261
14448
|
|
|
@@ -14275,6 +14462,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
14275
14462
|
|
|
14276
14463
|
def cmd_weekly(args: argparse.Namespace) -> int:
|
|
14277
14464
|
"""Show Claude usage grouped by subscription week."""
|
|
14465
|
+
_share_validate_args(args)
|
|
14278
14466
|
config = load_config()
|
|
14279
14467
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
14280
14468
|
|
|
@@ -14351,6 +14539,33 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
14351
14539
|
else:
|
|
14352
14540
|
overlay.append((None, None))
|
|
14353
14541
|
|
|
14542
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
14543
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
14544
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
14545
|
+
# Gate runs BEFORE the `--order desc` reversal so the BarChart bars
|
|
14546
|
+
# render chronologically regardless of `--order`. Table rows in the
|
|
14547
|
+
# rendered artifact respect `--order desc` (parity with terminal /
|
|
14548
|
+
# JSON) via post-build snap.rows reversal. `--breakdown` is honored:
|
|
14549
|
+
# when set, the snapshot adds per-model columns + stacked bar series
|
|
14550
|
+
# (vs. cmd_daily / cmd_monthly where --breakdown is a no-op under
|
|
14551
|
+
# --format).
|
|
14552
|
+
if getattr(args, "format", None):
|
|
14553
|
+
display_tz_str = _share_display_tz_label(args._resolved_tz)
|
|
14554
|
+
snap = _build_weekly_snapshot(
|
|
14555
|
+
buckets, overlay,
|
|
14556
|
+
period_start=range_start,
|
|
14557
|
+
period_end=range_end,
|
|
14558
|
+
display_tz=display_tz_str,
|
|
14559
|
+
version=_share_resolve_version(),
|
|
14560
|
+
theme=args.theme,
|
|
14561
|
+
reveal_projects=args.reveal_projects,
|
|
14562
|
+
breakdown_model=bool(getattr(args, "breakdown", False)),
|
|
14563
|
+
)
|
|
14564
|
+
if args.order == "desc":
|
|
14565
|
+
snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
|
|
14566
|
+
_share_render_and_emit(snap, args)
|
|
14567
|
+
return 0
|
|
14568
|
+
|
|
14354
14569
|
# Apply sort order. Buckets and overlay must reverse together so their
|
|
14355
14570
|
# indices stay aligned (both _render_weekly_table and _weekly_to_json
|
|
14356
14571
|
# assert len equality).
|
|
@@ -14819,6 +15034,7 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
|
|
|
14819
15034
|
|
|
14820
15035
|
def cmd_project(args: argparse.Namespace) -> int:
|
|
14821
15036
|
"""Roll entries up by project (git-root) with per-project usage attribution."""
|
|
15037
|
+
_share_validate_args(args)
|
|
14822
15038
|
config = load_config()
|
|
14823
15039
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
14824
15040
|
|
|
@@ -15157,6 +15373,33 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
15157
15373
|
key=lambda r: _project_sort_key(r, args.sort, args.order),
|
|
15158
15374
|
)
|
|
15159
15375
|
|
|
15376
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
15377
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
15378
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
15379
|
+
# Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
|
|
15380
|
+
# before rendering, so default output anonymizes project labels to
|
|
15381
|
+
# `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
|
|
15382
|
+
# The builder populates `ProjectCell.label` / `ChartPoint.project_label`
|
|
15383
|
+
# / `ChartPoint.x_label` with REAL names; the wrapper-level scrubber is
|
|
15384
|
+
# the single chokepoint that rewrites them.
|
|
15385
|
+
if getattr(args, "format", None):
|
|
15386
|
+
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
15387
|
+
# the headline per-project usage table + HBar chart; per-model
|
|
15388
|
+
# sub-rows aren't in the share spec scope). Same convention as
|
|
15389
|
+
# cmd_daily / cmd_weekly / cmd_report.
|
|
15390
|
+
display_tz_str = _share_display_tz_label(args._resolved_tz)
|
|
15391
|
+
snap = _build_project_snapshot(
|
|
15392
|
+
list(sorted_rows),
|
|
15393
|
+
period_start=since_dt,
|
|
15394
|
+
period_end=until_dt,
|
|
15395
|
+
display_tz=display_tz_str,
|
|
15396
|
+
version=_share_resolve_version(),
|
|
15397
|
+
theme=args.theme,
|
|
15398
|
+
reveal_projects=args.reveal_projects,
|
|
15399
|
+
)
|
|
15400
|
+
_share_render_and_emit(snap, args)
|
|
15401
|
+
return 0
|
|
15402
|
+
|
|
15160
15403
|
if args.json:
|
|
15161
15404
|
print(_project_json_output(
|
|
15162
15405
|
since=since_dt,
|
|
@@ -16762,6 +17005,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
16762
17005
|
|
|
16763
17006
|
def cmd_session(args: argparse.Namespace) -> int:
|
|
16764
17007
|
"""Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
|
|
17008
|
+
_share_validate_args(args)
|
|
16765
17009
|
config = load_config()
|
|
16766
17010
|
tz = resolve_display_tz(args, config)
|
|
16767
17011
|
args._resolved_tz = tz
|
|
@@ -16775,6 +17019,52 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
16775
17019
|
|
|
16776
17020
|
entries = get_claude_session_entries(range_start, range_end)
|
|
16777
17021
|
sessions = _aggregate_claude_sessions(entries)
|
|
17022
|
+
|
|
17023
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
17024
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
17025
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
17026
|
+
# Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
|
|
17027
|
+
# before rendering, so default output anonymizes project labels to
|
|
17028
|
+
# `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
|
|
17029
|
+
# The builder populates `ProjectCell.label` / `ChartPoint.project_label`
|
|
17030
|
+
# / `ChartPoint.x_label` with REAL basenames; the wrapper-level scrubber
|
|
17031
|
+
# is the single chokepoint that rewrites them.
|
|
17032
|
+
if getattr(args, "format", None):
|
|
17033
|
+
# --top-n validation. Spec convention (Implementor 6 fix-loop):
|
|
17034
|
+
# invalid flag combinations exit 2; the soft-warn upper threshold
|
|
17035
|
+
# (>50) writes to stderr but proceeds.
|
|
17036
|
+
top_n = getattr(args, "top_n", None)
|
|
17037
|
+
if top_n is not None:
|
|
17038
|
+
if top_n < 1:
|
|
17039
|
+
print(
|
|
17040
|
+
"cctally: --top-n must be >= 1",
|
|
17041
|
+
file=sys.stderr,
|
|
17042
|
+
)
|
|
17043
|
+
return 2
|
|
17044
|
+
if top_n > 50:
|
|
17045
|
+
sys.stderr.write(
|
|
17046
|
+
f"cctally: --top-n {top_n} will likely produce an "
|
|
17047
|
+
"unreadable chart (consider 15 or fewer)\n"
|
|
17048
|
+
)
|
|
17049
|
+
# Note: --breakdown is a no-op under --format (snapshot focuses
|
|
17050
|
+
# on the headline per-session usage table + HBar chart; per-model
|
|
17051
|
+
# sub-rows aren't in the share spec scope). Same convention as
|
|
17052
|
+
# cmd_daily / cmd_project.
|
|
17053
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
17054
|
+
snap = _build_session_snapshot(
|
|
17055
|
+
sessions,
|
|
17056
|
+
period_start=range_start,
|
|
17057
|
+
period_end=range_end,
|
|
17058
|
+
display_tz=display_tz_str,
|
|
17059
|
+
version=_share_resolve_version(),
|
|
17060
|
+
theme=args.theme,
|
|
17061
|
+
reveal_projects=args.reveal_projects,
|
|
17062
|
+
top_n=top_n,
|
|
17063
|
+
tz=tz,
|
|
17064
|
+
)
|
|
17065
|
+
_share_render_and_emit(snap, args)
|
|
17066
|
+
return 0
|
|
17067
|
+
|
|
16778
17068
|
# Aggregator returns descending by last_activity; --order asc reverses.
|
|
16779
17069
|
if args.order == "asc":
|
|
16780
17070
|
sessions = list(reversed(sessions))
|
|
@@ -18019,6 +18309,7 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
18019
18309
|
|
|
18020
18310
|
|
|
18021
18311
|
def cmd_report(args: argparse.Namespace) -> int:
|
|
18312
|
+
_share_validate_args(args)
|
|
18022
18313
|
if args.sync_current:
|
|
18023
18314
|
sync_ns = argparse.Namespace(
|
|
18024
18315
|
week_start=None,
|
|
@@ -18066,6 +18357,40 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
18066
18357
|
|
|
18067
18358
|
weeks = get_recent_weeks(conn, max(1, args.weeks))
|
|
18068
18359
|
if not weeks:
|
|
18360
|
+
# Format-aware empty path mirrors cmd_forecast:18578-18629 — a
|
|
18361
|
+
# fresh install requesting `report --format html` should emit a
|
|
18362
|
+
# uniformly-shaped artifact, not a free-form "No data yet"
|
|
18363
|
+
# sentence the share consumer can't parse.
|
|
18364
|
+
if getattr(args, "format", None):
|
|
18365
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
18366
|
+
# Anchor the period_label on the current subscription
|
|
18367
|
+
# week so the artifact's subtitle is meaningful (the
|
|
18368
|
+
# week the report WOULD describe if data existed).
|
|
18369
|
+
# `_command_as_of()` honors CCTALLY_AS_OF — keeps the
|
|
18370
|
+
# period_label coherent with `generated_at` (which goes
|
|
18371
|
+
# through `_share_now_utc` from the same env hook) so
|
|
18372
|
+
# fixture goldens don't drift when the harness host's
|
|
18373
|
+
# wall-clock day rolls past CCTALLY_AS_OF.
|
|
18374
|
+
now_local = _command_as_of().astimezone(tz)
|
|
18375
|
+
local_tz = now_local.tzinfo
|
|
18376
|
+
ws_d, we_d = compute_week_bounds(now_local, week_start_name)
|
|
18377
|
+
ws_dt = dt.datetime.combine(
|
|
18378
|
+
ws_d, dt.time.min, tzinfo=local_tz
|
|
18379
|
+
)
|
|
18380
|
+
we_dt = dt.datetime.combine(
|
|
18381
|
+
we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
18382
|
+
)
|
|
18383
|
+
snap = _build_report_snapshot(
|
|
18384
|
+
[],
|
|
18385
|
+
period_start=ws_dt,
|
|
18386
|
+
period_end=we_dt,
|
|
18387
|
+
display_tz=display_tz_str,
|
|
18388
|
+
version=_share_resolve_version(),
|
|
18389
|
+
theme=args.theme,
|
|
18390
|
+
reveal_projects=args.reveal_projects,
|
|
18391
|
+
)
|
|
18392
|
+
_share_render_and_emit(snap, args)
|
|
18393
|
+
return 0
|
|
18069
18394
|
if args.json:
|
|
18070
18395
|
print(json.dumps({"current": None, "trend": []}, indent=2))
|
|
18071
18396
|
else:
|
|
@@ -18178,6 +18503,39 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
18178
18503
|
for m in milestone_rows
|
|
18179
18504
|
]
|
|
18180
18505
|
|
|
18506
|
+
# Shareable-reports gate: --format short-circuits the terminal/JSON
|
|
18507
|
+
# paths. The mutex in `_add_share_args` guarantees --format and
|
|
18508
|
+
# --json are not both set, so checking --format first is unambiguous.
|
|
18509
|
+
# Snapshot rows are reversed to ascending chronological order so
|
|
18510
|
+
# the line chart trends left->right with time (`get_recent_weeks`
|
|
18511
|
+
# returns newest-first; `trend` mirrors that order).
|
|
18512
|
+
if getattr(args, "format", None):
|
|
18513
|
+
# Note: --detail is a no-op under --format (snapshot focuses on
|
|
18514
|
+
# the headline weekly-trend table + chart; per-percent milestone
|
|
18515
|
+
# detail isn't in the share spec scope). Same convention applies
|
|
18516
|
+
# to other share-enabled subcommands (cmd_daily's --breakdown,
|
|
18517
|
+
# etc.).
|
|
18518
|
+
ordered_trend = list(reversed(trend))
|
|
18519
|
+
if ordered_trend:
|
|
18520
|
+
first_wsd = ordered_trend[0].get("weekStartDate")
|
|
18521
|
+
last_wed = ordered_trend[-1].get("weekEndDate") or ordered_trend[-1].get("weekStartDate")
|
|
18522
|
+
period_start = _share_parse_date_to_dt(first_wsd, tz)
|
|
18523
|
+
period_end = _share_parse_date_to_dt(last_wed, tz)
|
|
18524
|
+
else:
|
|
18525
|
+
period_start = period_end = _share_now_utc()
|
|
18526
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
18527
|
+
snap = _build_report_snapshot(
|
|
18528
|
+
ordered_trend,
|
|
18529
|
+
period_start=period_start,
|
|
18530
|
+
period_end=period_end,
|
|
18531
|
+
display_tz=display_tz_str,
|
|
18532
|
+
version=_share_resolve_version(),
|
|
18533
|
+
theme=args.theme,
|
|
18534
|
+
reveal_projects=args.reveal_projects,
|
|
18535
|
+
)
|
|
18536
|
+
_share_render_and_emit(snap, args)
|
|
18537
|
+
return 0
|
|
18538
|
+
|
|
18181
18539
|
if args.json:
|
|
18182
18540
|
print(json.dumps(output, indent=2))
|
|
18183
18541
|
return 0
|
|
@@ -18289,6 +18647,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
18289
18647
|
"""Project current-week usage to reset boundary. Emit terminal report,
|
|
18290
18648
|
JSON, or status-line one-liner. See docs/commands/forecast.md.
|
|
18291
18649
|
"""
|
|
18650
|
+
_share_validate_args(args)
|
|
18292
18651
|
if args.json and args.status_line:
|
|
18293
18652
|
print("forecast: --json and --status-line are mutually exclusive",
|
|
18294
18653
|
file=sys.stderr)
|
|
@@ -18314,6 +18673,58 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
18314
18673
|
inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
|
|
18315
18674
|
if inputs is None:
|
|
18316
18675
|
# No snapshot for the current week.
|
|
18676
|
+
if getattr(args, "format", None):
|
|
18677
|
+
# Shareable-reports empty-data path: emit a "no data" snapshot
|
|
18678
|
+
# rather than a free-form text message so consumers of the share
|
|
18679
|
+
# output (md / html / svg) get a uniformly-shaped artifact.
|
|
18680
|
+
#
|
|
18681
|
+
# Compute the real subscription-week boundaries from config
|
|
18682
|
+
# rather than collapsing to a 0-duration `now → now` window —
|
|
18683
|
+
# the period_label in the artifact's subtitle is meaningful
|
|
18684
|
+
# (the week the forecast WOULD describe if data existed).
|
|
18685
|
+
# Lift the `dt.date` boundaries from `compute_week_bounds`
|
|
18686
|
+
# to tz-aware datetimes anchored on local midnight so the
|
|
18687
|
+
# PeriodSpec stays consistent with sibling builders.
|
|
18688
|
+
tz = getattr(args, "_resolved_tz", None)
|
|
18689
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
18690
|
+
week_start_name = get_week_start_name(
|
|
18691
|
+
config, getattr(args, "week_start_name", None)
|
|
18692
|
+
)
|
|
18693
|
+
ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
|
|
18694
|
+
# internal fallback: host-local intentional
|
|
18695
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
18696
|
+
week_start_dt = dt.datetime.combine(
|
|
18697
|
+
ws_date, dt.time.min, tzinfo=local_tz
|
|
18698
|
+
)
|
|
18699
|
+
week_end_dt = dt.datetime.combine(
|
|
18700
|
+
we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
18701
|
+
)
|
|
18702
|
+
# Pass `low_conf=False` + explicit notes: the issue is "no data
|
|
18703
|
+
# recorded yet," not "thin data." LOW CONF would mislead the
|
|
18704
|
+
# reader into thinking a projection ran with sparse samples.
|
|
18705
|
+
snap = _build_forecast_snapshot(
|
|
18706
|
+
week_start=week_start_dt,
|
|
18707
|
+
week_end=week_end_dt,
|
|
18708
|
+
display_tz=display_tz_str,
|
|
18709
|
+
version=_share_resolve_version(),
|
|
18710
|
+
theme=args.theme,
|
|
18711
|
+
reveal_projects=args.reveal_projects,
|
|
18712
|
+
actual_series=[],
|
|
18713
|
+
projected_series=[],
|
|
18714
|
+
current_pct=0.0,
|
|
18715
|
+
projected_low_pct=0.0,
|
|
18716
|
+
projected_high_pct=0.0,
|
|
18717
|
+
days_remaining=0.0,
|
|
18718
|
+
dollars_per_percent=0.0,
|
|
18719
|
+
dollars_per_percent_source="this_week",
|
|
18720
|
+
low_conf=False,
|
|
18721
|
+
notes=(
|
|
18722
|
+
"No snapshots recorded for this week yet — run "
|
|
18723
|
+
"cctally record-usage to populate.",
|
|
18724
|
+
),
|
|
18725
|
+
)
|
|
18726
|
+
_share_render_and_emit(snap, args)
|
|
18727
|
+
return 0
|
|
18317
18728
|
if args.json:
|
|
18318
18729
|
print(json.dumps({
|
|
18319
18730
|
"error": "no_current_week_data",
|
|
@@ -18327,6 +18738,94 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
18327
18738
|
|
|
18328
18739
|
output = _compute_forecast(inputs, targets)
|
|
18329
18740
|
|
|
18741
|
+
# Shareable-reports gate: --format short-circuits the JSON / status-line /
|
|
18742
|
+
# terminal dispatch via `_share_render_and_emit`. The mutex in
|
|
18743
|
+
# `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
|
|
18744
|
+
# `--status-line` from coexisting. The gate fires AFTER `_compute_forecast`
|
|
18745
|
+
# so the snapshot reuses the same projection math as the terminal/JSON
|
|
18746
|
+
# paths — no parallel computation.
|
|
18747
|
+
if getattr(args, "format", None):
|
|
18748
|
+
i = output.inputs
|
|
18749
|
+
# Re-fetch the samples for the LineChart's actual_series. The
|
|
18750
|
+
# `_load_forecast_inputs` path dropped them after deriving p_now /
|
|
18751
|
+
# p_24h_ago / snapshot_count; re-running `_fetch_current_week_snapshots`
|
|
18752
|
+
# is a single indexed query against `weekly_usage_snapshots` and only
|
|
18753
|
+
# fires when `--format` is requested, so the cost is bounded.
|
|
18754
|
+
# `_apply_midweek_reset_override` is replayed so the chart axis
|
|
18755
|
+
# matches the (possibly-shifted) week_start_at carried by `inputs`.
|
|
18756
|
+
fetched = _fetch_current_week_snapshots(conn, now_utc)
|
|
18757
|
+
actual_series: list[tuple[str, float, float]] = []
|
|
18758
|
+
if fetched is not None:
|
|
18759
|
+
_ws_at, _we_at, raw_samples = fetched
|
|
18760
|
+
_ws_at_shifted, samples = _apply_midweek_reset_override(
|
|
18761
|
+
conn, _ws_at, _we_at, raw_samples
|
|
18762
|
+
)
|
|
18763
|
+
tz_render = getattr(args, "_resolved_tz", None)
|
|
18764
|
+
for cap_at, pct, _five_hr in samples:
|
|
18765
|
+
elapsed_h = (
|
|
18766
|
+
(cap_at - i.week_start_at).total_seconds() / 3600.0
|
|
18767
|
+
)
|
|
18768
|
+
lbl = format_display_dt(
|
|
18769
|
+
cap_at, tz_render, fmt="%a %H:%M", suffix=False,
|
|
18770
|
+
)
|
|
18771
|
+
actual_series.append((lbl, elapsed_h, float(pct)))
|
|
18772
|
+
# Projected series: a 2-point ray from (now, p_now) to
|
|
18773
|
+
# (week_end, projected_high). The high-end matches the terminal
|
|
18774
|
+
# render's "may cap" warning so the chart and table tell the same
|
|
18775
|
+
# story. When `already_capped` is true the ray collapses to a flat
|
|
18776
|
+
# horizontal line at p_now from (now → week_end) — visually
|
|
18777
|
+
# signals "you are pinned at the cap; no further growth expected"
|
|
18778
|
+
# instead of the visually-empty (no-projection) chart that was
|
|
18779
|
+
# confusable with "no projection computed."
|
|
18780
|
+
projected_series: list[tuple[str, float, float]] = []
|
|
18781
|
+
if i.remaining_hours > 0:
|
|
18782
|
+
tz_render = getattr(args, "_resolved_tz", None)
|
|
18783
|
+
now_label = format_display_dt(
|
|
18784
|
+
i.now_utc, tz_render, fmt="%a %H:%M", suffix=False,
|
|
18785
|
+
)
|
|
18786
|
+
end_label = format_display_dt(
|
|
18787
|
+
i.week_end_at, tz_render, fmt="%a %H:%M", suffix=False,
|
|
18788
|
+
)
|
|
18789
|
+
now_x = (i.now_utc - i.week_start_at).total_seconds() / 3600.0
|
|
18790
|
+
end_x = (i.week_end_at - i.week_start_at).total_seconds() / 3600.0
|
|
18791
|
+
if output.already_capped:
|
|
18792
|
+
# Flat ray: y stays at p_now across the remaining window.
|
|
18793
|
+
projected_series.append(
|
|
18794
|
+
(now_label, now_x, float(i.p_now))
|
|
18795
|
+
)
|
|
18796
|
+
projected_series.append(
|
|
18797
|
+
(end_label, end_x, float(i.p_now))
|
|
18798
|
+
)
|
|
18799
|
+
else:
|
|
18800
|
+
projected_series.append(
|
|
18801
|
+
(now_label, now_x, float(i.p_now))
|
|
18802
|
+
)
|
|
18803
|
+
projected_series.append(
|
|
18804
|
+
(end_label, end_x, float(output.final_percent_high))
|
|
18805
|
+
)
|
|
18806
|
+
display_tz_str = _share_display_tz_label(
|
|
18807
|
+
getattr(args, "_resolved_tz", None)
|
|
18808
|
+
)
|
|
18809
|
+
snap = _build_forecast_snapshot(
|
|
18810
|
+
week_start=i.week_start_at,
|
|
18811
|
+
week_end=i.week_end_at,
|
|
18812
|
+
display_tz=display_tz_str,
|
|
18813
|
+
version=_share_resolve_version(),
|
|
18814
|
+
theme=args.theme,
|
|
18815
|
+
reveal_projects=args.reveal_projects,
|
|
18816
|
+
actual_series=actual_series,
|
|
18817
|
+
projected_series=projected_series,
|
|
18818
|
+
current_pct=float(i.p_now),
|
|
18819
|
+
projected_low_pct=float(output.final_percent_low),
|
|
18820
|
+
projected_high_pct=float(output.final_percent_high),
|
|
18821
|
+
days_remaining=float(i.remaining_days),
|
|
18822
|
+
dollars_per_percent=float(i.dollars_per_percent),
|
|
18823
|
+
dollars_per_percent_source=i.dollars_per_percent_source,
|
|
18824
|
+
low_conf=(i.confidence == "low"),
|
|
18825
|
+
)
|
|
18826
|
+
_share_render_and_emit(snap, args)
|
|
18827
|
+
return 0
|
|
18828
|
+
|
|
18330
18829
|
if args.json:
|
|
18331
18830
|
print(_emit_forecast_json(output))
|
|
18332
18831
|
return 0
|
|
@@ -18755,6 +19254,7 @@ def _render_five_hour_blocks_table(
|
|
|
18755
19254
|
|
|
18756
19255
|
def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
18757
19256
|
"""List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
|
|
19257
|
+
_share_validate_args(args)
|
|
18758
19258
|
config = load_config()
|
|
18759
19259
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
18760
19260
|
# Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
|
|
@@ -18831,6 +19331,61 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
18831
19331
|
d["seven_day_pct_at_block_end"] = latest_7d
|
|
18832
19332
|
block_dicts.append(d)
|
|
18833
19333
|
|
|
19334
|
+
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
19335
|
+
# dispatch via `_share_render_and_emit`. The mutex in
|
|
19336
|
+
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
19337
|
+
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
19338
|
+
# the headline 5h-block trend; per-axis sub-rows aren't in the
|
|
19339
|
+
# share spec scope). Cross-reset blocks render with `▲` x-axis
|
|
19340
|
+
# markers in the BarChart and `⚡` glyphs in the table cell —
|
|
19341
|
+
# both signals route to the share renderer's UTF-8-safe paths.
|
|
19342
|
+
# Gate runs BEFORE the optional `_load_breakdown` loop so a
|
|
19343
|
+
# 50-block --format invocation doesn't pay 50 wasted SQLite
|
|
19344
|
+
# queries the snapshot would discard.
|
|
19345
|
+
if getattr(args, "format", None):
|
|
19346
|
+
display_tz_str = _share_display_tz_label(args._resolved_tz)
|
|
19347
|
+
# Period bounds: prefer the user's --since/--until filter
|
|
19348
|
+
# window; fall back to oldest/newest block timestamps when no
|
|
19349
|
+
# filter was applied so the period label reflects what the
|
|
19350
|
+
# snapshot actually covers.
|
|
19351
|
+
# block_dicts is DESC-ordered: [-1] is oldest, [0] is newest.
|
|
19352
|
+
if since_iso:
|
|
19353
|
+
period_start = _share_parse_date_to_dt(
|
|
19354
|
+
since_iso, args._resolved_tz,
|
|
19355
|
+
)
|
|
19356
|
+
elif block_dicts:
|
|
19357
|
+
tail = block_dicts[-1].get("block_start_at")
|
|
19358
|
+
period_start = _share_parse_date_to_dt(
|
|
19359
|
+
(tail or "").split("T")[0] or None,
|
|
19360
|
+
args._resolved_tz,
|
|
19361
|
+
)
|
|
19362
|
+
else:
|
|
19363
|
+
period_start = _share_now_utc()
|
|
19364
|
+
if until_iso:
|
|
19365
|
+
period_end = _share_parse_date_to_dt(
|
|
19366
|
+
until_iso, args._resolved_tz,
|
|
19367
|
+
)
|
|
19368
|
+
elif block_dicts:
|
|
19369
|
+
head = block_dicts[0].get("block_start_at")
|
|
19370
|
+
period_end = _share_parse_date_to_dt(
|
|
19371
|
+
(head or "").split("T")[0] or None,
|
|
19372
|
+
args._resolved_tz,
|
|
19373
|
+
)
|
|
19374
|
+
else:
|
|
19375
|
+
period_end = _share_now_utc()
|
|
19376
|
+
snap = _build_five_hour_blocks_snapshot(
|
|
19377
|
+
block_dicts,
|
|
19378
|
+
period_start=period_start,
|
|
19379
|
+
period_end=period_end,
|
|
19380
|
+
display_tz=display_tz_str,
|
|
19381
|
+
version=_share_resolve_version(),
|
|
19382
|
+
theme=args.theme,
|
|
19383
|
+
reveal_projects=args.reveal_projects,
|
|
19384
|
+
tz=args._resolved_tz,
|
|
19385
|
+
)
|
|
19386
|
+
_share_render_and_emit(snap, args)
|
|
19387
|
+
return 0
|
|
19388
|
+
|
|
18834
19389
|
# Optional breakdown.
|
|
18835
19390
|
if args.breakdown:
|
|
18836
19391
|
for bd in block_dicts:
|
|
@@ -19241,6 +19796,90 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
19241
19796
|
conn.close()
|
|
19242
19797
|
|
|
19243
19798
|
if not should_insert:
|
|
19799
|
+
# Self-heal: a prior record-usage invocation may have inserted
|
|
19800
|
+
# the snapshot but been killed (CC self-update, machine sleep,
|
|
19801
|
+
# OOM) before maybe_record_milestone / maybe_update_five_hour_block
|
|
19802
|
+
# could run. Pre-probe both surfaces with cheap indexed SELECTs
|
|
19803
|
+
# and only invoke the helpers when a row is actually missing or
|
|
19804
|
+
# stale. Steady-state cost: 1-3 SELECTs (latest snapshot always;
|
|
19805
|
+
# +max_milestone if floor>=1; +block last_observed if window_key
|
|
19806
|
+
# is set); ZERO JSONL re-ingest on healthy ticks. The helpers themselves are idempotent under
|
|
19807
|
+
# concurrent record-usage instances (INSERT OR IGNORE for
|
|
19808
|
+
# percent_milestones; SQLite write-lock serialization for the
|
|
19809
|
+
# 5h upsert). Without the pre-probe, every dedup tick would
|
|
19810
|
+
# trigger sync_cache + a window walk + replace-all rollups via
|
|
19811
|
+
# maybe_update_five_hour_block's unconditional _compute_block_totals
|
|
19812
|
+
# call. Regression: bin/cctally-record-usage-selfheal-test.
|
|
19813
|
+
try:
|
|
19814
|
+
heal_conn = open_db()
|
|
19815
|
+
try:
|
|
19816
|
+
latest_row = heal_conn.execute(
|
|
19817
|
+
"SELECT * FROM weekly_usage_snapshots "
|
|
19818
|
+
"WHERE week_start_date = ? "
|
|
19819
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
19820
|
+
(week_start_date,),
|
|
19821
|
+
).fetchone()
|
|
19822
|
+
if latest_row is None:
|
|
19823
|
+
return 0
|
|
19824
|
+
|
|
19825
|
+
# Probe 1: do we owe a percent milestone? Snap up before
|
|
19826
|
+
# floor (status-line API returns 0.N*100 which can fall
|
|
19827
|
+
# one ULP short of N — same convention as
|
|
19828
|
+
# maybe_record_milestone).
|
|
19829
|
+
latest_floor = math.floor(
|
|
19830
|
+
float(latest_row["weekly_percent"]) + 1e-9
|
|
19831
|
+
)
|
|
19832
|
+
need_milestone_heal = False
|
|
19833
|
+
if latest_floor >= 1:
|
|
19834
|
+
max_existing = heal_conn.execute(
|
|
19835
|
+
"SELECT MAX(percent_threshold) AS m "
|
|
19836
|
+
"FROM percent_milestones "
|
|
19837
|
+
"WHERE week_start_date = ?",
|
|
19838
|
+
(week_start_date,),
|
|
19839
|
+
).fetchone()
|
|
19840
|
+
if max_existing is None or max_existing["m"] is None:
|
|
19841
|
+
need_milestone_heal = True
|
|
19842
|
+
elif int(max_existing["m"]) < latest_floor:
|
|
19843
|
+
need_milestone_heal = True
|
|
19844
|
+
|
|
19845
|
+
# Probe 2: do we owe a 5h-block update? Either no row
|
|
19846
|
+
# for this canonical window, or the existing row's
|
|
19847
|
+
# last_observed_at_utc is stale relative to the latest
|
|
19848
|
+
# snapshot's captured_at_utc (the kill landed between
|
|
19849
|
+
# insert_usage_snapshot and maybe_update_five_hour_block).
|
|
19850
|
+
need_5h_heal = False
|
|
19851
|
+
window_key = latest_row["five_hour_window_key"]
|
|
19852
|
+
if window_key is not None:
|
|
19853
|
+
block_row = heal_conn.execute(
|
|
19854
|
+
"SELECT last_observed_at_utc "
|
|
19855
|
+
"FROM five_hour_blocks "
|
|
19856
|
+
"WHERE five_hour_window_key = ?",
|
|
19857
|
+
(int(window_key),),
|
|
19858
|
+
).fetchone()
|
|
19859
|
+
if block_row is None:
|
|
19860
|
+
need_5h_heal = True
|
|
19861
|
+
elif (
|
|
19862
|
+
block_row["last_observed_at_utc"]
|
|
19863
|
+
< latest_row["captured_at_utc"]
|
|
19864
|
+
):
|
|
19865
|
+
need_5h_heal = True
|
|
19866
|
+
finally:
|
|
19867
|
+
heal_conn.close()
|
|
19868
|
+
|
|
19869
|
+
if need_milestone_heal or need_5h_heal:
|
|
19870
|
+
latest_saved = _saved_dict_from_usage_row(latest_row)
|
|
19871
|
+
if need_milestone_heal:
|
|
19872
|
+
try:
|
|
19873
|
+
maybe_record_milestone(latest_saved)
|
|
19874
|
+
except Exception as exc:
|
|
19875
|
+
eprint(f"[milestone] self-heal error: {exc}")
|
|
19876
|
+
if need_5h_heal:
|
|
19877
|
+
try:
|
|
19878
|
+
maybe_update_five_hour_block(latest_saved)
|
|
19879
|
+
except Exception as exc:
|
|
19880
|
+
eprint(f"[5h-block] self-heal error: {exc}")
|
|
19881
|
+
except Exception as exc:
|
|
19882
|
+
eprint(f"[record-usage] self-heal lookup failed: {exc}")
|
|
19244
19883
|
return 0
|
|
19245
19884
|
|
|
19246
19885
|
payload = {
|
|
@@ -20430,6 +21069,44 @@ def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict
|
|
|
20430
21069
|
return out
|
|
20431
21070
|
|
|
20432
21071
|
|
|
21072
|
+
def _saved_dict_from_usage_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
21073
|
+
"""Mirror ``insert_usage_snapshot``'s output dict from an existing
|
|
21074
|
+
weekly_usage_snapshots row. Used by ``cmd_record_usage``'s dedup
|
|
21075
|
+
self-heal path so ``maybe_record_milestone`` and
|
|
21076
|
+
``maybe_update_five_hour_block`` can re-run on the latest snapshot
|
|
21077
|
+
when an earlier invocation was killed between snapshot insert and
|
|
21078
|
+
milestone insert (e.g. CC self-update kill window, 2026-05-08).
|
|
21079
|
+
|
|
21080
|
+
Field omissions match ``insert_usage_snapshot``: keys whose values
|
|
21081
|
+
would be ``None`` are not emitted, so downstream ``saved.get(...)``
|
|
21082
|
+
callers see the same shape they'd see on a fresh insert.
|
|
21083
|
+
|
|
21084
|
+
Note: ``resetText`` (the only userscript-payload-only key
|
|
21085
|
+
``insert_usage_snapshot`` re-emits in its output dict) is
|
|
21086
|
+
intentionally omitted — no downstream ``saved``-dict consumer in
|
|
21087
|
+
this codebase reads it. ``pageUrl`` is a column on
|
|
21088
|
+
``weekly_usage_snapshots`` but is never propagated into the output
|
|
21089
|
+
dict either path.
|
|
21090
|
+
"""
|
|
21091
|
+
out: dict[str, Any] = {
|
|
21092
|
+
"id": int(row["id"]),
|
|
21093
|
+
"capturedAt": row["captured_at_utc"],
|
|
21094
|
+
"weekStartDate": row["week_start_date"],
|
|
21095
|
+
"weekEndDate": row["week_end_date"],
|
|
21096
|
+
"weeklyPercent": float(row["weekly_percent"]),
|
|
21097
|
+
}
|
|
21098
|
+
if row["week_start_at"] is not None:
|
|
21099
|
+
out["weekStartAt"] = row["week_start_at"]
|
|
21100
|
+
if row["week_end_at"] is not None:
|
|
21101
|
+
out["weekEndAt"] = row["week_end_at"]
|
|
21102
|
+
if row["five_hour_percent"] is not None:
|
|
21103
|
+
out["fiveHourPercent"] = float(row["five_hour_percent"])
|
|
21104
|
+
if row["five_hour_resets_at"] is not None:
|
|
21105
|
+
out["fiveHourResetsAt"] = row["five_hour_resets_at"]
|
|
21106
|
+
if row["five_hour_window_key"] is not None:
|
|
21107
|
+
out["fiveHourWindowKey"] = int(row["five_hour_window_key"])
|
|
21108
|
+
return out
|
|
21109
|
+
|
|
20433
21110
|
|
|
20434
21111
|
def _resolve_cache_report_window(
|
|
20435
21112
|
args: argparse.Namespace,
|
|
@@ -22265,6 +22942,142 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
|
|
|
22265
22942
|
return 0
|
|
22266
22943
|
|
|
22267
22944
|
|
|
22945
|
+
def _argparse_has_arg(parser, option_string: str) -> bool:
|
|
22946
|
+
"""Return True if ``parser`` already registered ``option_string``."""
|
|
22947
|
+
for action in parser._actions:
|
|
22948
|
+
if option_string in (action.option_strings or ()):
|
|
22949
|
+
return True
|
|
22950
|
+
return False
|
|
22951
|
+
|
|
22952
|
+
|
|
22953
|
+
def _add_share_args(parser, *, has_status_line: bool = False) -> None:
|
|
22954
|
+
"""Attach shareable-reports flags + format/json mutex to a subparser.
|
|
22955
|
+
|
|
22956
|
+
Idempotent — call exactly once per subparser. Caller MUST remove any
|
|
22957
|
+
pre-existing ``--json`` (and ``--status-line`` for forecast) from the
|
|
22958
|
+
subparser before invoking this helper, so the mutex group owns those
|
|
22959
|
+
flags. Raises ``RuntimeError`` on contract violation — surfaces at
|
|
22960
|
+
parser-build time (i.e., on every CLI invocation, including ``--help``)
|
|
22961
|
+
instead of at the user invocation that hits the unguarded
|
|
22962
|
+
``--format --json`` combo. The prior shape silently skipped re-adding,
|
|
22963
|
+
leaving the mutex unenforced for any future 9th share-enabled subparser
|
|
22964
|
+
whose existing ``--json`` was accidentally left in place.
|
|
22965
|
+
"""
|
|
22966
|
+
if _argparse_has_arg(parser, "--json"):
|
|
22967
|
+
raise RuntimeError(
|
|
22968
|
+
f"_add_share_args: parser {parser.prog!r} already has --json; "
|
|
22969
|
+
"remove it before calling _add_share_args so mutex applies"
|
|
22970
|
+
)
|
|
22971
|
+
if has_status_line and _argparse_has_arg(parser, "--status-line"):
|
|
22972
|
+
raise RuntimeError(
|
|
22973
|
+
f"_add_share_args: parser {parser.prog!r} already has --status-line; "
|
|
22974
|
+
"remove it before calling _add_share_args(has_status_line=True)"
|
|
22975
|
+
)
|
|
22976
|
+
output_group = parser.add_mutually_exclusive_group()
|
|
22977
|
+
output_group.add_argument(
|
|
22978
|
+
"--format", choices=("md", "html", "svg"),
|
|
22979
|
+
help="Render output as shareable markdown, self-contained HTML, or SVG. "
|
|
22980
|
+
"Default destination: md->stdout, html/svg->~/Downloads file.")
|
|
22981
|
+
output_group.add_argument(
|
|
22982
|
+
"--json", action="store_true",
|
|
22983
|
+
help="Emit machine-readable JSON; suppresses terminal render.")
|
|
22984
|
+
if has_status_line:
|
|
22985
|
+
output_group.add_argument(
|
|
22986
|
+
"--status-line", action="store_true", dest="status_line",
|
|
22987
|
+
help="Emit one-line compact string for status-line injection.")
|
|
22988
|
+
|
|
22989
|
+
parser.add_argument(
|
|
22990
|
+
"--theme", choices=("light", "dark"), default="light",
|
|
22991
|
+
help="Color theme for HTML/SVG (default: light). No-op for markdown.")
|
|
22992
|
+
parser.add_argument(
|
|
22993
|
+
"--no-branding", action="store_true", dest="no_branding",
|
|
22994
|
+
help="Strip the 'Generated by cctally' footer from --format output.")
|
|
22995
|
+
parser.add_argument(
|
|
22996
|
+
"--output", metavar="PATH",
|
|
22997
|
+
help="Write --format output to PATH instead of the default destination "
|
|
22998
|
+
"(stdout for md; ~/Downloads/cctally-<cmd>-<utcdate>.<ext> for html/svg). "
|
|
22999
|
+
"Use '-' for stdout.")
|
|
23000
|
+
parser.add_argument(
|
|
23001
|
+
"--copy", action="store_true",
|
|
23002
|
+
help="Pipe --format md output to clipboard (pbcopy/xclip/clip). "
|
|
23003
|
+
"Rejected for html/svg.")
|
|
23004
|
+
parser.add_argument(
|
|
23005
|
+
"--open", action="store_true", dest="open_after_write",
|
|
23006
|
+
help="After writing --format html/svg to a file, open it in the default app. "
|
|
23007
|
+
"Rejected for md.")
|
|
23008
|
+
|
|
23009
|
+
|
|
23010
|
+
def _share_validate_args(args) -> None:
|
|
23011
|
+
"""Reject share flag combinations BEFORE any DB / sync / render work.
|
|
23012
|
+
|
|
23013
|
+
Two layers of validation:
|
|
23014
|
+
|
|
23015
|
+
1. Share-only flags (``--output``, ``--copy``, ``--open``) require
|
|
23016
|
+
``--format``. Silent dropping trains users to assume the file
|
|
23017
|
+
was written.
|
|
23018
|
+
|
|
23019
|
+
2. Destination-shape combinations (``--copy`` + ``--output``,
|
|
23020
|
+
``--copy`` + non-md, ``--open`` + md, ``--open`` + ``--output -``).
|
|
23021
|
+
These were previously caught only inside ``_resolve_destination``
|
|
23022
|
+
/ ``_share_render_and_emit`` — i.e. AFTER ``--sync-current`` had
|
|
23023
|
+
already mutated the DB and the snapshot had been built. Surfacing
|
|
23024
|
+
them at validation time means an exit-2 flag-shape error never
|
|
23025
|
+
triggers side effects.
|
|
23026
|
+
|
|
23027
|
+
Exit 2 with a stderr message naming the offending combo so the
|
|
23028
|
+
failure is loud and scriptable. Idempotent; safe to call from every
|
|
23029
|
+
share-enabled subcommand before the ``--format`` gate. Existing
|
|
23030
|
+
late checks in ``_resolve_destination`` / ``_share_render_and_emit``
|
|
23031
|
+
are kept as defense-in-depth for any future caller that bypasses
|
|
23032
|
+
this helper.
|
|
23033
|
+
"""
|
|
23034
|
+
if not getattr(args, "format", None):
|
|
23035
|
+
offenders = []
|
|
23036
|
+
if getattr(args, "output", None):
|
|
23037
|
+
offenders.append("--output")
|
|
23038
|
+
if getattr(args, "copy", False):
|
|
23039
|
+
offenders.append("--copy")
|
|
23040
|
+
if getattr(args, "open_after_write", False):
|
|
23041
|
+
offenders.append("--open")
|
|
23042
|
+
if not offenders:
|
|
23043
|
+
return
|
|
23044
|
+
verb = "requires" if len(offenders) == 1 else "require"
|
|
23045
|
+
sys.stderr.write(
|
|
23046
|
+
f"cctally: {', '.join(offenders)} {verb} --format\n"
|
|
23047
|
+
)
|
|
23048
|
+
sys.exit(2)
|
|
23049
|
+
|
|
23050
|
+
# --format is set — validate destination-shape combos.
|
|
23051
|
+
fmt = args.format
|
|
23052
|
+
copy = getattr(args, "copy", False)
|
|
23053
|
+
output = getattr(args, "output", None)
|
|
23054
|
+
open_after_write = getattr(args, "open_after_write", False)
|
|
23055
|
+
|
|
23056
|
+
if copy and output is not None:
|
|
23057
|
+
# Mutex: a clipboard destination by definition has no path.
|
|
23058
|
+
sys.stderr.write(
|
|
23059
|
+
"cctally: --copy is mutually exclusive with --output\n"
|
|
23060
|
+
)
|
|
23061
|
+
sys.exit(2)
|
|
23062
|
+
if copy and fmt != "md":
|
|
23063
|
+
sys.stderr.write(
|
|
23064
|
+
"cctally: --copy is only valid with --format md\n"
|
|
23065
|
+
)
|
|
23066
|
+
sys.exit(2)
|
|
23067
|
+
if open_after_write and fmt == "md":
|
|
23068
|
+
sys.stderr.write(
|
|
23069
|
+
"cctally: --open is only valid with --format html or --format svg\n"
|
|
23070
|
+
)
|
|
23071
|
+
sys.exit(2)
|
|
23072
|
+
if open_after_write and output == "-":
|
|
23073
|
+
# Open-after-write to stdout has no file to launch — was a silent
|
|
23074
|
+
# no-op pre-fix; now an explicit exit 2 so users notice.
|
|
23075
|
+
sys.stderr.write(
|
|
23076
|
+
"cctally: --open is incompatible with --output - (no file to open)\n"
|
|
23077
|
+
)
|
|
23078
|
+
sys.exit(2)
|
|
23079
|
+
|
|
23080
|
+
|
|
22268
23081
|
def build_parser() -> argparse.ArgumentParser:
|
|
22269
23082
|
p = argparse.ArgumentParser(
|
|
22270
23083
|
prog="cctally",
|
|
@@ -22429,9 +23242,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
22429
23242
|
help="Project filter passed to sync-week when --sync-current is used.",
|
|
22430
23243
|
)
|
|
22431
23244
|
pr.add_argument(
|
|
22432
|
-
"--
|
|
23245
|
+
"--reveal-projects",
|
|
22433
23246
|
action="store_true",
|
|
22434
|
-
|
|
23247
|
+
dest="reveal_projects",
|
|
23248
|
+
help="In --format output, show real project basenames instead of "
|
|
23249
|
+
"the default project-1, project-2, ... anonymization.",
|
|
22435
23250
|
)
|
|
22436
23251
|
pr.add_argument(
|
|
22437
23252
|
"--detail",
|
|
@@ -22443,6 +23258,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
22443
23258
|
help="Display timezone: local, utc, or IANA name. "
|
|
22444
23259
|
"Overrides config display.tz for this call.",
|
|
22445
23260
|
)
|
|
23261
|
+
_add_share_args(pr)
|
|
22446
23262
|
pr.set_defaults(func=cmd_report)
|
|
22447
23263
|
|
|
22448
23264
|
fc = sub.add_parser(
|
|
@@ -22470,10 +23286,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
22470
23286
|
"""
|
|
22471
23287
|
),
|
|
22472
23288
|
)
|
|
22473
|
-
fc.add_argument("--
|
|
22474
|
-
help="
|
|
22475
|
-
|
|
22476
|
-
help="Emit one-line compact string for status-line injection.")
|
|
23289
|
+
fc.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
23290
|
+
help="In --format output, show real project basenames instead of "
|
|
23291
|
+
"the default project-1, project-2, ... anonymization.")
|
|
22477
23292
|
fc.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
22478
23293
|
help="Display timezone: local, utc, or IANA name. "
|
|
22479
23294
|
"Overrides config display.tz for this call.")
|
|
@@ -22487,6 +23302,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
22487
23302
|
help="Color output control (also honors NO_COLOR).")
|
|
22488
23303
|
# Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
|
|
22489
23304
|
fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
|
|
23305
|
+
_add_share_args(fc, has_status_line=True)
|
|
22490
23306
|
fc.set_defaults(func=cmd_forecast)
|
|
22491
23307
|
|
|
22492
23308
|
pb = sub.add_parser(
|
|
@@ -23058,9 +23874,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23058
23874
|
help="Add per-axis rollup-child rows under each block.",
|
|
23059
23875
|
)
|
|
23060
23876
|
fhb.add_argument(
|
|
23061
|
-
"--
|
|
23877
|
+
"--reveal-projects",
|
|
23062
23878
|
action="store_true",
|
|
23063
|
-
|
|
23879
|
+
dest="reveal_projects",
|
|
23880
|
+
help="In --format output, show real project basenames instead of "
|
|
23881
|
+
"the default project-1, project-2, ... anonymization.",
|
|
23064
23882
|
)
|
|
23065
23883
|
fhb.add_argument(
|
|
23066
23884
|
"--no-color",
|
|
@@ -23075,6 +23893,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23075
23893
|
help="Display timezone: local, utc, or IANA name. "
|
|
23076
23894
|
"Overrides config display.tz for this call.",
|
|
23077
23895
|
)
|
|
23896
|
+
_add_share_args(fhb)
|
|
23078
23897
|
fhb.set_defaults(func=cmd_five_hour_blocks)
|
|
23079
23898
|
|
|
23080
23899
|
# -- cache-sync --
|
|
@@ -23134,16 +23953,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23134
23953
|
help="Sort direction by date (default: asc).",
|
|
23135
23954
|
)
|
|
23136
23955
|
dy.add_argument(
|
|
23137
|
-
"--
|
|
23956
|
+
"--reveal-projects",
|
|
23138
23957
|
action="store_true",
|
|
23139
|
-
dest="
|
|
23140
|
-
help="
|
|
23958
|
+
dest="reveal_projects",
|
|
23959
|
+
help="In --format output, show real project basenames instead of "
|
|
23960
|
+
"the default project-1, project-2, ... anonymization.",
|
|
23141
23961
|
)
|
|
23142
23962
|
dy.add_argument(
|
|
23143
23963
|
"--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
23144
23964
|
help="Display timezone: local, utc, or IANA name. "
|
|
23145
23965
|
"Overrides config display.tz for this call.",
|
|
23146
23966
|
)
|
|
23967
|
+
_add_share_args(dy)
|
|
23147
23968
|
dy.set_defaults(func=cmd_daily)
|
|
23148
23969
|
|
|
23149
23970
|
# -- monthly --
|
|
@@ -23185,16 +24006,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23185
24006
|
help="Sort direction by month (default: asc).",
|
|
23186
24007
|
)
|
|
23187
24008
|
mo.add_argument(
|
|
23188
|
-
"--
|
|
24009
|
+
"--reveal-projects",
|
|
23189
24010
|
action="store_true",
|
|
23190
|
-
dest="
|
|
23191
|
-
help="
|
|
24011
|
+
dest="reveal_projects",
|
|
24012
|
+
help="In --format output, show real project basenames instead of "
|
|
24013
|
+
"the default project-1, project-2, ... anonymization.",
|
|
23192
24014
|
)
|
|
23193
24015
|
mo.add_argument(
|
|
23194
24016
|
"--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
23195
24017
|
help="Display timezone: local, utc, or IANA name. "
|
|
23196
24018
|
"Overrides config display.tz for this call.",
|
|
23197
24019
|
)
|
|
24020
|
+
_add_share_args(mo)
|
|
23198
24021
|
mo.set_defaults(func=cmd_monthly)
|
|
23199
24022
|
|
|
23200
24023
|
# -- weekly --
|
|
@@ -23223,11 +24046,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23223
24046
|
help="Show per-model cost breakdown sub-rows.")
|
|
23224
24047
|
we.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
|
|
23225
24048
|
help="Sort direction by week (default: asc).")
|
|
23226
|
-
we.add_argument("--
|
|
23227
|
-
help="
|
|
24049
|
+
we.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
24050
|
+
help="In --format output, show real project basenames instead of "
|
|
24051
|
+
"the default project-1, project-2, ... anonymization.")
|
|
23228
24052
|
we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
23229
24053
|
help="Display timezone: local, utc, or IANA name. "
|
|
23230
24054
|
"Overrides config display.tz for this call.")
|
|
24055
|
+
_add_share_args(we)
|
|
23231
24056
|
we.set_defaults(func=cmd_weekly)
|
|
23232
24057
|
|
|
23233
24058
|
# -- codex shared args helper --
|
|
@@ -23423,13 +24248,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23423
24248
|
help="Sort key (default: cost).")
|
|
23424
24249
|
p_project.add_argument("--group", choices=("git-root", "full-path"), default="git-root",
|
|
23425
24250
|
help="Bucket by resolved git-root (default) or raw project_path.")
|
|
23426
|
-
p_project.add_argument("--
|
|
23427
|
-
help="
|
|
24251
|
+
p_project.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
24252
|
+
help="In --format output, show real project basenames instead of "
|
|
24253
|
+
"the default project-1, project-2, ... anonymization.")
|
|
23428
24254
|
p_project.add_argument("--no-color", action="store_true", dest="no_color",
|
|
23429
24255
|
help="Disable ANSI color.")
|
|
23430
24256
|
p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
23431
24257
|
help="Display timezone: local, utc, or IANA name. "
|
|
23432
24258
|
"Overrides config display.tz for this call.")
|
|
24259
|
+
_add_share_args(p_project)
|
|
23433
24260
|
p_project.set_defaults(func=cmd_project)
|
|
23434
24261
|
|
|
23435
24262
|
# -- diff --
|
|
@@ -23490,11 +24317,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23490
24317
|
help="Show per-model cost breakdown sub-rows.")
|
|
23491
24318
|
se.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
|
|
23492
24319
|
help="Sort direction by last activity (default: asc — earliest first).")
|
|
23493
|
-
se.add_argument("--
|
|
23494
|
-
help="
|
|
24320
|
+
se.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
24321
|
+
help="In --format output, show real project basenames instead of "
|
|
24322
|
+
"the default project-1, project-2, ... anonymization.")
|
|
24323
|
+
se.add_argument("--top-n", type=int, default=15, dest="top_n",
|
|
24324
|
+
metavar="N",
|
|
24325
|
+
help="In --format output, cap rows to top N by cost (default: 15). "
|
|
24326
|
+
"Must be >= 1; values above 50 emit a readability warning. "
|
|
24327
|
+
"Has no effect on terminal/JSON output.")
|
|
23495
24328
|
se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
23496
24329
|
help="Display timezone: local, utc, or IANA name. "
|
|
23497
24330
|
"Overrides config display.tz for this call.")
|
|
24331
|
+
_add_share_args(se)
|
|
23498
24332
|
se.set_defaults(func=cmd_session)
|
|
23499
24333
|
|
|
23500
24334
|
# ---- config (persisted user preferences) ----
|
|
@@ -23828,6 +24662,1544 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
23828
24662
|
return p
|
|
23829
24663
|
|
|
23830
24664
|
|
|
24665
|
+
# ============================================================
|
|
24666
|
+
# ==== Shareable reports: destination + emit ==== =
|
|
24667
|
+
# ============================================================
|
|
24668
|
+
# Translate parsed argparse args + a rendered string into actual delivery
|
|
24669
|
+
# (stdout / file / clipboard / open). These helpers live here, NOT in
|
|
24670
|
+
# `_lib_share.py`, so the kernel module stays I/O-pure (Section 5.8 of the
|
|
24671
|
+
# shareable-reports spec).
|
|
24672
|
+
|
|
24673
|
+
|
|
24674
|
+
# Module-level latch for the home-dir fallback hint. Spec Section 4.2 calls
|
|
24675
|
+
# for a one-shot stderr suggestion when share output lands in $HOME because
|
|
24676
|
+
# both XDG_DOWNLOAD_DIR and ~/Downloads were absent. Latched here (process
|
|
24677
|
+
# scope) so a user running, e.g., a `cctally daily --format html` followed
|
|
24678
|
+
# by `cctally weekly --format html` in the same shell sees the hint exactly
|
|
24679
|
+
# once. Tests reset by reaching into the module globals if needed.
|
|
24680
|
+
_DOWNLOADS_HOME_HINT_EMITTED = False
|
|
24681
|
+
|
|
24682
|
+
|
|
24683
|
+
def _share_resolve_download_dir() -> pathlib.Path:
|
|
24684
|
+
"""XDG -> ~/Downloads -> ~ fallback (Section 4.2)."""
|
|
24685
|
+
global _DOWNLOADS_HOME_HINT_EMITTED
|
|
24686
|
+
xdg = os.environ.get("XDG_DOWNLOAD_DIR")
|
|
24687
|
+
if xdg:
|
|
24688
|
+
p = pathlib.Path(xdg).expanduser()
|
|
24689
|
+
if p.exists():
|
|
24690
|
+
return p
|
|
24691
|
+
downloads = pathlib.Path.home() / "Downloads"
|
|
24692
|
+
if downloads.exists():
|
|
24693
|
+
return downloads
|
|
24694
|
+
if not _DOWNLOADS_HOME_HINT_EMITTED:
|
|
24695
|
+
sys.stderr.write(
|
|
24696
|
+
"cctally: writing share output to home dir; "
|
|
24697
|
+
"pass --output <path> to choose a destination\n"
|
|
24698
|
+
)
|
|
24699
|
+
_DOWNLOADS_HOME_HINT_EMITTED = True
|
|
24700
|
+
return pathlib.Path.home()
|
|
24701
|
+
|
|
24702
|
+
|
|
24703
|
+
def _share_unique_path(base: pathlib.Path) -> pathlib.Path:
|
|
24704
|
+
"""Auto-collision counter — base.html -> base-2.html -> base-3.html -> ... cap 99.
|
|
24705
|
+
|
|
24706
|
+
Exhaustion (>99 same-day collisions) exits 3 per spec Section 4.4. Prior
|
|
24707
|
+
code raised ``SystemExit("…")`` which yields exit 1 — broke the spec's
|
|
24708
|
+
distinct-exit-code contract for collision exhaustion vs. generic errors.
|
|
24709
|
+
"""
|
|
24710
|
+
if not base.exists():
|
|
24711
|
+
return base
|
|
24712
|
+
stem = base.stem
|
|
24713
|
+
suffix = base.suffix
|
|
24714
|
+
parent = base.parent
|
|
24715
|
+
for n in range(2, 100):
|
|
24716
|
+
candidate = parent / f"{stem}-{n}{suffix}"
|
|
24717
|
+
if not candidate.exists():
|
|
24718
|
+
return candidate
|
|
24719
|
+
print(
|
|
24720
|
+
f"cctally: too many same-day collisions in {parent}; use --output <path>",
|
|
24721
|
+
file=sys.stderr,
|
|
24722
|
+
)
|
|
24723
|
+
sys.exit(3)
|
|
24724
|
+
|
|
24725
|
+
|
|
24726
|
+
def _resolve_destination(
|
|
24727
|
+
args, *, cmd: str, generated_at_utc_date: str
|
|
24728
|
+
) -> tuple[str, pathlib.Path | None]:
|
|
24729
|
+
"""Translate argparse args into (kind, value).
|
|
24730
|
+
|
|
24731
|
+
kind: "stdout" | "file" | "clipboard"
|
|
24732
|
+
value: pathlib.Path for "file"; None for "stdout" / "clipboard".
|
|
24733
|
+
|
|
24734
|
+
Exit-code contract (spec Section 4.4):
|
|
24735
|
+
- exit 2 on invalid flag combinations (--copy on non-md;
|
|
24736
|
+
--copy + --output; --copy with no clipboard tool; --open + md).
|
|
24737
|
+
- exit 3 on collision exhaustion (delegated to _share_unique_path).
|
|
24738
|
+
"""
|
|
24739
|
+
fmt = args.format
|
|
24740
|
+
if getattr(args, "copy", False) and getattr(args, "output", None) is not None:
|
|
24741
|
+
# Mutex: a clipboard destination by definition has no path. Spec
|
|
24742
|
+
# Section 4.4 line 132 calls this out explicitly. Prior code silently
|
|
24743
|
+
# let --copy override --output, which surprised users who expected
|
|
24744
|
+
# the file to land alongside the clipboard write.
|
|
24745
|
+
print(
|
|
24746
|
+
"cctally: --copy is mutually exclusive with --output",
|
|
24747
|
+
file=sys.stderr,
|
|
24748
|
+
)
|
|
24749
|
+
sys.exit(2)
|
|
24750
|
+
if getattr(args, "copy", False):
|
|
24751
|
+
if fmt != "md":
|
|
24752
|
+
print("cctally: --copy is only valid with --format md", file=sys.stderr)
|
|
24753
|
+
sys.exit(2)
|
|
24754
|
+
return ("clipboard", None)
|
|
24755
|
+
|
|
24756
|
+
output = getattr(args, "output", None)
|
|
24757
|
+
if output == "-":
|
|
24758
|
+
return ("stdout", None)
|
|
24759
|
+
if output:
|
|
24760
|
+
return ("file", pathlib.Path(output).expanduser())
|
|
24761
|
+
|
|
24762
|
+
if fmt == "md":
|
|
24763
|
+
return ("stdout", None)
|
|
24764
|
+
# html/svg default -> ~/Downloads/cctally-<cmd>-<utcdate>.<ext>
|
|
24765
|
+
base = _share_resolve_download_dir() / f"cctally-{cmd}-{generated_at_utc_date}.{fmt}"
|
|
24766
|
+
return ("file", _share_unique_path(base))
|
|
24767
|
+
|
|
24768
|
+
|
|
24769
|
+
def _emit(content: str, *, kind: str, value: pathlib.Path | str | None) -> None:
|
|
24770
|
+
"""Deliver rendered content to stdout/file/clipboard."""
|
|
24771
|
+
if kind == "stdout":
|
|
24772
|
+
sys.stdout.write(content)
|
|
24773
|
+
if not content.endswith("\n"):
|
|
24774
|
+
sys.stdout.write("\n")
|
|
24775
|
+
return
|
|
24776
|
+
|
|
24777
|
+
if kind == "file":
|
|
24778
|
+
path = pathlib.Path(value)
|
|
24779
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24780
|
+
path.write_text(content, encoding="utf-8")
|
|
24781
|
+
sys.stderr.write(f"Wrote {path}\n")
|
|
24782
|
+
return
|
|
24783
|
+
|
|
24784
|
+
if kind == "clipboard":
|
|
24785
|
+
# Track tools that were found-but-failed separately from "no tool on
|
|
24786
|
+
# PATH" so the error message accurately describes what went wrong.
|
|
24787
|
+
# The prior shape ("requires pbcopy/xclip/clip on PATH") was
|
|
24788
|
+
# misleading when e.g. pbcopy was present but exited non-zero.
|
|
24789
|
+
tried = []
|
|
24790
|
+
for cmd_args in (
|
|
24791
|
+
["pbcopy"],
|
|
24792
|
+
["xclip", "-sel", "clip"],
|
|
24793
|
+
["clip.exe"],
|
|
24794
|
+
):
|
|
24795
|
+
tool = cmd_args[0]
|
|
24796
|
+
if shutil.which(tool):
|
|
24797
|
+
proc = subprocess.run(cmd_args, input=content, text=True, check=False)
|
|
24798
|
+
if proc.returncode == 0:
|
|
24799
|
+
sys.stderr.write(f"Copied to clipboard via {tool}\n")
|
|
24800
|
+
return
|
|
24801
|
+
tried.append(f"{tool} (exit {proc.returncode})")
|
|
24802
|
+
if tried:
|
|
24803
|
+
print(
|
|
24804
|
+
f"cctally: clipboard tool failed: {', '.join(tried)}",
|
|
24805
|
+
file=sys.stderr,
|
|
24806
|
+
)
|
|
24807
|
+
sys.exit(2)
|
|
24808
|
+
print(
|
|
24809
|
+
"cctally: --copy requires pbcopy, xclip, or clip on PATH",
|
|
24810
|
+
file=sys.stderr,
|
|
24811
|
+
)
|
|
24812
|
+
sys.exit(2)
|
|
24813
|
+
|
|
24814
|
+
raise ValueError(f"unknown destination kind: {kind!r}")
|
|
24815
|
+
|
|
24816
|
+
|
|
24817
|
+
def _share_load_lib():
|
|
24818
|
+
"""Lazy-load `_lib_share` with sys.modules caching.
|
|
24819
|
+
|
|
24820
|
+
Single-load semantics keep ShareSnapshot / MoneyCell / etc. class
|
|
24821
|
+
identities stable across kernel imports: the test harness pre-registers
|
|
24822
|
+
`_lib_share` in `sys.modules`, the wrapper imports it via this helper,
|
|
24823
|
+
and snapshot builders import it via this helper — all paths must see
|
|
24824
|
+
the SAME module object so `isinstance` checks on snapshot cells compare
|
|
24825
|
+
across one class identity, not many. This is the chokepoint for the
|
|
24826
|
+
duplicate-class-identity bug surfaced under the test harness in
|
|
24827
|
+
Implementor 6's fix-loop.
|
|
24828
|
+
|
|
24829
|
+
Registers in sys.modules BEFORE exec_module: Python 3.14's `dataclass`
|
|
24830
|
+
decorator looks up `cls.__module__` in `sys.modules` for `KW_ONLY` type
|
|
24831
|
+
checks, and an absent entry would re-trigger the dual-load path under
|
|
24832
|
+
some import orders.
|
|
24833
|
+
"""
|
|
24834
|
+
cached = sys.modules.get("_lib_share")
|
|
24835
|
+
if cached is not None:
|
|
24836
|
+
return cached
|
|
24837
|
+
import importlib.util as _ilu
|
|
24838
|
+
_lib_share_path = pathlib.Path(__file__).resolve().parent / "_lib_share.py"
|
|
24839
|
+
_spec = _ilu.spec_from_file_location("_lib_share", _lib_share_path)
|
|
24840
|
+
_mod = _ilu.module_from_spec(_spec)
|
|
24841
|
+
sys.modules["_lib_share"] = _mod
|
|
24842
|
+
_spec.loader.exec_module(_mod)
|
|
24843
|
+
return _mod
|
|
24844
|
+
|
|
24845
|
+
|
|
24846
|
+
def _share_now_utc() -> dt.datetime:
|
|
24847
|
+
"""`generated_at` source — honors CCTALLY_AS_OF env hook for fixture stability.
|
|
24848
|
+
|
|
24849
|
+
Mirrors the existing `CCTALLY_AS_OF` precedent used by `project` /
|
|
24850
|
+
`forecast` for deterministic fixture goldens. Format: ISO-8601 with `Z`
|
|
24851
|
+
or explicit offset (e.g. `2026-05-09T12:00:00Z` or
|
|
24852
|
+
`2026-05-09T12:00:00+00:00`); falls back to wall-clock UTC when unset.
|
|
24853
|
+
|
|
24854
|
+
Raises ValueError on malformed `CCTALLY_AS_OF` input — deliberate
|
|
24855
|
+
fail-loud behavior for the dev hook so fixture authors notice typos
|
|
24856
|
+
immediately rather than silently falling back to wall-clock time.
|
|
24857
|
+
"""
|
|
24858
|
+
override = os.environ.get("CCTALLY_AS_OF")
|
|
24859
|
+
if override:
|
|
24860
|
+
parsed = dt.datetime.fromisoformat(override.replace("Z", "+00:00"))
|
|
24861
|
+
if parsed.tzinfo is None:
|
|
24862
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
24863
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
24864
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
24865
|
+
|
|
24866
|
+
|
|
24867
|
+
def _share_resolve_version() -> str:
|
|
24868
|
+
"""Source from CHANGELOG via the existing release helper. Empty string if unset.
|
|
24869
|
+
|
|
24870
|
+
`_release_read_latest_release_version` returns `(version, date) | None`;
|
|
24871
|
+
the snapshot's `version` field carries the version string only.
|
|
24872
|
+
"""
|
|
24873
|
+
info = _release_read_latest_release_version()
|
|
24874
|
+
return info[0] if info else ""
|
|
24875
|
+
|
|
24876
|
+
|
|
24877
|
+
def _share_period_label(
|
|
24878
|
+
period_start: dt.datetime,
|
|
24879
|
+
period_end: dt.datetime,
|
|
24880
|
+
display_tz_label: str,
|
|
24881
|
+
) -> str:
|
|
24882
|
+
"""Render the canonical "<start> → <end> (<tz>)" period label.
|
|
24883
|
+
|
|
24884
|
+
Used by both the report and daily snapshot builders so the period label
|
|
24885
|
+
format stays consistent across share-enabled subcommands.
|
|
24886
|
+
"""
|
|
24887
|
+
return (
|
|
24888
|
+
f"{period_start.strftime('%b %d')} → "
|
|
24889
|
+
f"{period_end.strftime('%b %d')} ({display_tz_label})"
|
|
24890
|
+
)
|
|
24891
|
+
|
|
24892
|
+
|
|
24893
|
+
def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
|
|
24894
|
+
"""Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
|
|
24895
|
+
|
|
24896
|
+
Used by the share gate sites to lift week-boundary date strings
|
|
24897
|
+
(`weekStartDate`, `weekEndDate`) into the tz-aware datetimes that
|
|
24898
|
+
`PeriodSpec` expects. None / empty / unparseable -> current UTC; the
|
|
24899
|
+
caller already gated on a non-empty trend before reaching this path,
|
|
24900
|
+
so the fallback is purely defensive against missing-data corner cases.
|
|
24901
|
+
"""
|
|
24902
|
+
if value is None:
|
|
24903
|
+
return _share_now_utc()
|
|
24904
|
+
if isinstance(value, dt.datetime):
|
|
24905
|
+
return value if value.tzinfo else value.replace(tzinfo=tz or dt.timezone.utc)
|
|
24906
|
+
if isinstance(value, dt.date):
|
|
24907
|
+
d = value
|
|
24908
|
+
else:
|
|
24909
|
+
try:
|
|
24910
|
+
d = dt.date.fromisoformat(str(value))
|
|
24911
|
+
except ValueError:
|
|
24912
|
+
return _share_now_utc()
|
|
24913
|
+
midnight = dt.datetime(d.year, d.month, d.day)
|
|
24914
|
+
return midnight.replace(tzinfo=tz or dt.timezone.utc)
|
|
24915
|
+
|
|
24916
|
+
|
|
24917
|
+
def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
|
|
24918
|
+
"""Render a stable display-tz string for `PeriodSpec.display_tz`.
|
|
24919
|
+
|
|
24920
|
+
`resolve_display_tz` returns `None` for "local" (caller does bare
|
|
24921
|
+
astimezone); the share snapshot needs a non-None string. Map None ->
|
|
24922
|
+
"local" and use ZoneInfo.key otherwise.
|
|
24923
|
+
"""
|
|
24924
|
+
return tz.key if tz is not None else "local"
|
|
24925
|
+
|
|
24926
|
+
|
|
24927
|
+
def _build_report_snapshot(
|
|
24928
|
+
rows: list[dict[str, object]],
|
|
24929
|
+
*,
|
|
24930
|
+
period_start: dt.datetime,
|
|
24931
|
+
period_end: dt.datetime,
|
|
24932
|
+
display_tz: str,
|
|
24933
|
+
version: str,
|
|
24934
|
+
theme: str,
|
|
24935
|
+
reveal_projects: bool,
|
|
24936
|
+
) -> "ShareSnapshot":
|
|
24937
|
+
"""Build a ShareSnapshot for `cctally report`.
|
|
24938
|
+
|
|
24939
|
+
`rows` is the in-memory `trend` list produced by `cmd_report` — a list
|
|
24940
|
+
of dicts keyed in the existing JSON-shape camelCase
|
|
24941
|
+
(`weekStartDate`, `weeklyPercent`, `weeklyCostUSD`, `dollarsPerPercent`).
|
|
24942
|
+
The plan's snake_case keys (`week_start_date`, `used_pct`, `cost_usd`,
|
|
24943
|
+
`dollar_per_pct`) are NOT the actual `cmd_report` data shape — see
|
|
24944
|
+
Implementor 7 commit body for the deviation.
|
|
24945
|
+
|
|
24946
|
+
Caller MUST pass `rows` in chronological order (oldest first) so the
|
|
24947
|
+
chart line trends left→right with time. `cmd_report`'s native `trend`
|
|
24948
|
+
is newest-first; the gate site reverses before calling.
|
|
24949
|
+
|
|
24950
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
24951
|
+
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
24952
|
+
the gate site. The forward-reference return type matches the kernel's
|
|
24953
|
+
lazy-import boundary.
|
|
24954
|
+
"""
|
|
24955
|
+
_lib_share = _share_load_lib()
|
|
24956
|
+
columns = (
|
|
24957
|
+
_lib_share.ColumnSpec(key="week", label="Week", align="left"),
|
|
24958
|
+
_lib_share.ColumnSpec(key="used", label="% Used", align="right"),
|
|
24959
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
24960
|
+
_lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
|
|
24961
|
+
emphasis=True),
|
|
24962
|
+
)
|
|
24963
|
+
snap_rows: list = []
|
|
24964
|
+
chart_pts: list = []
|
|
24965
|
+
for i, r in enumerate(rows):
|
|
24966
|
+
wsd = r.get("weekStartDate")
|
|
24967
|
+
# `weekStartDate` is sourced from `week_ref.week_start.isoformat()`
|
|
24968
|
+
# — guaranteed `str`. Empty / unparseable falls back to em-dash.
|
|
24969
|
+
if isinstance(wsd, str) and wsd:
|
|
24970
|
+
try:
|
|
24971
|
+
week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
|
|
24972
|
+
except ValueError:
|
|
24973
|
+
week_label = wsd
|
|
24974
|
+
else:
|
|
24975
|
+
week_label = "—"
|
|
24976
|
+
# Preserve None vs 0.0 distinction (parity with terminal/JSON).
|
|
24977
|
+
# Terminal _render_weekly_table renders missing values as "—";
|
|
24978
|
+
# share artifact follows the same convention. Coercing None to
|
|
24979
|
+
# 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
|
|
24980
|
+
# genuine zero, and would skew the avg / chart.
|
|
24981
|
+
used_pct_raw = r.get("weeklyPercent")
|
|
24982
|
+
cost_raw = r.get("weeklyCostUSD")
|
|
24983
|
+
dpp_raw = r.get("dollarsPerPercent")
|
|
24984
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
24985
|
+
"week": _lib_share.TextCell(week_label),
|
|
24986
|
+
"used": (
|
|
24987
|
+
_lib_share.PercentCell(float(used_pct_raw))
|
|
24988
|
+
if used_pct_raw is not None else _lib_share.TextCell("—")
|
|
24989
|
+
),
|
|
24990
|
+
"cost": (
|
|
24991
|
+
_lib_share.MoneyCell(float(cost_raw))
|
|
24992
|
+
if cost_raw is not None else _lib_share.TextCell("—")
|
|
24993
|
+
),
|
|
24994
|
+
"dpp": (
|
|
24995
|
+
_lib_share.MoneyCell(float(dpp_raw))
|
|
24996
|
+
if dpp_raw is not None else _lib_share.TextCell("—")
|
|
24997
|
+
),
|
|
24998
|
+
}))
|
|
24999
|
+
# Skip chart points for weeks with no $/% sample — the polyline
|
|
25000
|
+
# connects across the gap rather than dropping to 0, which would
|
|
25001
|
+
# misrepresent missing data as a crash to zero.
|
|
25002
|
+
if dpp_raw is not None:
|
|
25003
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25004
|
+
x_label=week_label,
|
|
25005
|
+
x_value=float(i),
|
|
25006
|
+
y_value=float(dpp_raw),
|
|
25007
|
+
))
|
|
25008
|
+
chart = (
|
|
25009
|
+
_lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
|
|
25010
|
+
if len(chart_pts) >= 3 else None
|
|
25011
|
+
)
|
|
25012
|
+
avg_dpp = (
|
|
25013
|
+
sum(p.y_value for p in chart_pts) / len(chart_pts)
|
|
25014
|
+
if chart_pts else 0.0
|
|
25015
|
+
)
|
|
25016
|
+
totals = (
|
|
25017
|
+
_lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
|
|
25018
|
+
)
|
|
25019
|
+
if rows:
|
|
25020
|
+
title = f"Weekly $ / % trend — last {len(rows)} weeks"
|
|
25021
|
+
else:
|
|
25022
|
+
title = "Weekly $ / % trend — no data"
|
|
25023
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
25024
|
+
subtitle = " · ".join([
|
|
25025
|
+
period_label,
|
|
25026
|
+
theme,
|
|
25027
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25028
|
+
])
|
|
25029
|
+
return _lib_share.ShareSnapshot(
|
|
25030
|
+
cmd="report",
|
|
25031
|
+
title=title,
|
|
25032
|
+
subtitle=subtitle,
|
|
25033
|
+
period=_lib_share.PeriodSpec(
|
|
25034
|
+
start=period_start, end=period_end,
|
|
25035
|
+
display_tz=display_tz, label=period_label,
|
|
25036
|
+
),
|
|
25037
|
+
columns=columns, rows=tuple(snap_rows),
|
|
25038
|
+
chart=chart, totals=totals, notes=(),
|
|
25039
|
+
generated_at=_share_now_utc(), version=version,
|
|
25040
|
+
)
|
|
25041
|
+
|
|
25042
|
+
|
|
25043
|
+
def _build_daily_snapshot(
|
|
25044
|
+
rows: list["BucketUsage"],
|
|
25045
|
+
*,
|
|
25046
|
+
period_start: dt.datetime,
|
|
25047
|
+
period_end: dt.datetime,
|
|
25048
|
+
display_tz: str,
|
|
25049
|
+
version: str,
|
|
25050
|
+
theme: str,
|
|
25051
|
+
reveal_projects: bool,
|
|
25052
|
+
) -> "ShareSnapshot":
|
|
25053
|
+
"""Build a ShareSnapshot for `cctally daily`.
|
|
25054
|
+
|
|
25055
|
+
`rows` is the in-memory `BucketUsage` list produced by `_aggregate_daily`
|
|
25056
|
+
inside `cmd_daily`. Each bucket has: `bucket` (YYYY-MM-DD date string),
|
|
25057
|
+
`cost_usd`, `model_breakdowns` (list[dict] sorted by cost desc; first
|
|
25058
|
+
entry is the top model).
|
|
25059
|
+
|
|
25060
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
25061
|
+
`date` / `cost_usd` / `pct_of_week` / `top_model`):
|
|
25062
|
+
|
|
25063
|
+
- Rows are `BucketUsage` dataclasses; we read fields by attribute.
|
|
25064
|
+
- Daily has no native `% of week` column — daily is range-scoped, not
|
|
25065
|
+
week-scoped. We render `% of period` (this row's cost / total range
|
|
25066
|
+
cost) so the column carries meaningful info; the `pct_week` key
|
|
25067
|
+
survives in the column spec for plan-shape parity.
|
|
25068
|
+
- `top_model` is the first entry of `model_breakdowns` (sorted by cost
|
|
25069
|
+
desc per upstream ccusage parity); empty → "—".
|
|
25070
|
+
|
|
25071
|
+
Caller MUST pass `rows` in chronological order so the BarChart bars
|
|
25072
|
+
line up left-to-right with time. `_aggregate_daily` already returns
|
|
25073
|
+
asc; the gate site re-sorts after `args.order == "desc"` was applied.
|
|
25074
|
+
|
|
25075
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
25076
|
+
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
25077
|
+
the gate site.
|
|
25078
|
+
"""
|
|
25079
|
+
_lib_share = _share_load_lib()
|
|
25080
|
+
columns = (
|
|
25081
|
+
_lib_share.ColumnSpec(key="date", label="Date", align="left"),
|
|
25082
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
25083
|
+
emphasis=True),
|
|
25084
|
+
_lib_share.ColumnSpec(key="pct_week", label="% of Period",
|
|
25085
|
+
align="right"),
|
|
25086
|
+
_lib_share.ColumnSpec(key="top_model", label="Top Model",
|
|
25087
|
+
align="left"),
|
|
25088
|
+
)
|
|
25089
|
+
total_cost = 0.0
|
|
25090
|
+
for r in rows:
|
|
25091
|
+
total_cost += float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
25092
|
+
|
|
25093
|
+
snap_rows: list = []
|
|
25094
|
+
chart_pts: list = []
|
|
25095
|
+
for i, r in enumerate(rows):
|
|
25096
|
+
# `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
|
|
25097
|
+
# empty / unparseable but skip the dead `dt.date` branch.
|
|
25098
|
+
bucket = getattr(r, "bucket", None)
|
|
25099
|
+
if isinstance(bucket, str) and bucket:
|
|
25100
|
+
try:
|
|
25101
|
+
date_str = dt.date.fromisoformat(bucket).strftime("%b %d")
|
|
25102
|
+
except ValueError:
|
|
25103
|
+
date_str = bucket
|
|
25104
|
+
else:
|
|
25105
|
+
date_str = "—"
|
|
25106
|
+
cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
25107
|
+
breakdowns = getattr(r, "model_breakdowns", None) or []
|
|
25108
|
+
top_model = (breakdowns[0].get("modelName") if breakdowns else None) or "—"
|
|
25109
|
+
pct_of_period = (cost_usd / total_cost * 100.0) if total_cost > 0 else 0.0
|
|
25110
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
25111
|
+
"date": _lib_share.TextCell(date_str),
|
|
25112
|
+
"cost": _lib_share.MoneyCell(cost_usd),
|
|
25113
|
+
"pct_week": _lib_share.PercentCell(pct_of_period),
|
|
25114
|
+
"top_model": _lib_share.TextCell(top_model),
|
|
25115
|
+
}))
|
|
25116
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25117
|
+
x_label=date_str,
|
|
25118
|
+
x_value=float(i),
|
|
25119
|
+
y_value=cost_usd,
|
|
25120
|
+
))
|
|
25121
|
+
chart = (
|
|
25122
|
+
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
25123
|
+
if chart_pts else None
|
|
25124
|
+
)
|
|
25125
|
+
avg_cost = (total_cost / len(chart_pts)) if chart_pts else 0.0
|
|
25126
|
+
totals = (
|
|
25127
|
+
_lib_share.Totalled(label="Sum", value=f"${total_cost:,.2f}"),
|
|
25128
|
+
_lib_share.Totalled(label="Days", value=str(len(chart_pts))),
|
|
25129
|
+
_lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
|
|
25130
|
+
)
|
|
25131
|
+
if rows:
|
|
25132
|
+
title = (
|
|
25133
|
+
f"Daily usage — {period_start.strftime('%b %d')} → "
|
|
25134
|
+
f"{period_end.strftime('%b %d')}"
|
|
25135
|
+
)
|
|
25136
|
+
else:
|
|
25137
|
+
title = "Daily usage — no data"
|
|
25138
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
25139
|
+
subtitle = " · ".join([
|
|
25140
|
+
period_label,
|
|
25141
|
+
theme,
|
|
25142
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25143
|
+
])
|
|
25144
|
+
return _lib_share.ShareSnapshot(
|
|
25145
|
+
cmd="daily",
|
|
25146
|
+
title=title,
|
|
25147
|
+
subtitle=subtitle,
|
|
25148
|
+
period=_lib_share.PeriodSpec(
|
|
25149
|
+
start=period_start, end=period_end,
|
|
25150
|
+
display_tz=display_tz, label=period_label,
|
|
25151
|
+
),
|
|
25152
|
+
columns=columns, rows=tuple(snap_rows),
|
|
25153
|
+
chart=chart, totals=totals, notes=(),
|
|
25154
|
+
generated_at=_share_now_utc(), version=version,
|
|
25155
|
+
)
|
|
25156
|
+
|
|
25157
|
+
|
|
25158
|
+
def _build_monthly_snapshot(
|
|
25159
|
+
rows: list["BucketUsage"],
|
|
25160
|
+
*,
|
|
25161
|
+
period_start: dt.datetime,
|
|
25162
|
+
period_end: dt.datetime,
|
|
25163
|
+
display_tz: str,
|
|
25164
|
+
version: str,
|
|
25165
|
+
theme: str,
|
|
25166
|
+
reveal_projects: bool,
|
|
25167
|
+
) -> "ShareSnapshot":
|
|
25168
|
+
"""Build a ShareSnapshot for `cctally monthly`.
|
|
25169
|
+
|
|
25170
|
+
`rows` is the in-memory `BucketUsage` list produced by `_aggregate_monthly`
|
|
25171
|
+
inside `cmd_monthly`. Each bucket has: `bucket` (YYYY-MM string),
|
|
25172
|
+
`cost_usd`, and `model_breakdowns` (list[dict] sorted by cost desc).
|
|
25173
|
+
|
|
25174
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
25175
|
+
`month` / `cost_usd` / `sessions`):
|
|
25176
|
+
|
|
25177
|
+
- Rows are `BucketUsage` dataclasses; we read fields by attribute.
|
|
25178
|
+
- The plan's `Sessions` column has no source in the underlying data
|
|
25179
|
+
(`BucketUsage` carries no session count and `_aggregate_monthly`
|
|
25180
|
+
never computes one). Substituted with a `Tokens` column carrying
|
|
25181
|
+
total tokens — meaningful info already on the dataclass.
|
|
25182
|
+
- `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
|
|
25183
|
+
months, matching the plan's intent.
|
|
25184
|
+
|
|
25185
|
+
Caller MUST pass `rows` in chronological order so the BarChart bars line
|
|
25186
|
+
up left-to-right with time. `_aggregate_monthly` already returns asc;
|
|
25187
|
+
the gate site fires before the `--order desc` reversal in `cmd_monthly`.
|
|
25188
|
+
|
|
25189
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
25190
|
+
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
25191
|
+
the gate site.
|
|
25192
|
+
"""
|
|
25193
|
+
_lib_share = _share_load_lib()
|
|
25194
|
+
columns = (
|
|
25195
|
+
_lib_share.ColumnSpec(key="month", label="Month", align="left"),
|
|
25196
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
25197
|
+
emphasis=True),
|
|
25198
|
+
_lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
25199
|
+
_lib_share.ColumnSpec(key="delta", label="Δ vs prior", align="right"),
|
|
25200
|
+
)
|
|
25201
|
+
snap_rows: list = []
|
|
25202
|
+
chart_pts: list = []
|
|
25203
|
+
prev_cost: float | None = None
|
|
25204
|
+
for i, r in enumerate(rows):
|
|
25205
|
+
# `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
|
|
25206
|
+
# empty / unparseable but skip the dead `dt.date` branch.
|
|
25207
|
+
bucket = getattr(r, "bucket", None)
|
|
25208
|
+
month_str = bucket if isinstance(bucket, str) and bucket else "—"
|
|
25209
|
+
cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
25210
|
+
total_tokens = int(getattr(r, "total_tokens", 0) or 0)
|
|
25211
|
+
if prev_cost is not None and prev_cost > 0:
|
|
25212
|
+
delta_pct = (cost_usd - prev_cost) / prev_cost * 100.0
|
|
25213
|
+
delta_cell = _lib_share.DeltaCell(value=delta_pct, unit="%")
|
|
25214
|
+
else:
|
|
25215
|
+
delta_cell = _lib_share.TextCell("—")
|
|
25216
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
25217
|
+
"month": _lib_share.TextCell(month_str),
|
|
25218
|
+
"cost": _lib_share.MoneyCell(cost_usd),
|
|
25219
|
+
"tokens": _lib_share.TextCell(f"{total_tokens:,}"),
|
|
25220
|
+
"delta": delta_cell,
|
|
25221
|
+
}))
|
|
25222
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25223
|
+
x_label=month_str,
|
|
25224
|
+
x_value=float(i),
|
|
25225
|
+
y_value=cost_usd,
|
|
25226
|
+
))
|
|
25227
|
+
prev_cost = cost_usd
|
|
25228
|
+
chart = (
|
|
25229
|
+
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
25230
|
+
if chart_pts else None
|
|
25231
|
+
)
|
|
25232
|
+
sum_cost = sum(p.y_value for p in chart_pts)
|
|
25233
|
+
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
25234
|
+
totals = (
|
|
25235
|
+
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
25236
|
+
_lib_share.Totalled(label="Months", value=str(len(chart_pts))),
|
|
25237
|
+
_lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
|
|
25238
|
+
)
|
|
25239
|
+
if rows:
|
|
25240
|
+
title = (
|
|
25241
|
+
f"Monthly usage — {period_start.strftime('%Y-%m')} → "
|
|
25242
|
+
f"{period_end.strftime('%Y-%m')}"
|
|
25243
|
+
)
|
|
25244
|
+
else:
|
|
25245
|
+
title = "Monthly usage — no data"
|
|
25246
|
+
period_label = (
|
|
25247
|
+
f"{period_start.strftime('%Y-%m')} → "
|
|
25248
|
+
f"{period_end.strftime('%Y-%m')} ({display_tz})"
|
|
25249
|
+
)
|
|
25250
|
+
subtitle = " · ".join([
|
|
25251
|
+
period_label,
|
|
25252
|
+
theme,
|
|
25253
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25254
|
+
])
|
|
25255
|
+
return _lib_share.ShareSnapshot(
|
|
25256
|
+
cmd="monthly",
|
|
25257
|
+
title=title,
|
|
25258
|
+
subtitle=subtitle,
|
|
25259
|
+
period=_lib_share.PeriodSpec(
|
|
25260
|
+
start=period_start, end=period_end,
|
|
25261
|
+
display_tz=display_tz, label=period_label,
|
|
25262
|
+
),
|
|
25263
|
+
columns=columns, rows=tuple(snap_rows),
|
|
25264
|
+
chart=chart, totals=totals, notes=(),
|
|
25265
|
+
generated_at=_share_now_utc(), version=version,
|
|
25266
|
+
)
|
|
25267
|
+
|
|
25268
|
+
|
|
25269
|
+
def _build_weekly_snapshot(
|
|
25270
|
+
rows: list["BucketUsage"],
|
|
25271
|
+
overlay: list[tuple[float | None, float | None]],
|
|
25272
|
+
*,
|
|
25273
|
+
period_start: dt.datetime,
|
|
25274
|
+
period_end: dt.datetime,
|
|
25275
|
+
display_tz: str,
|
|
25276
|
+
version: str,
|
|
25277
|
+
theme: str,
|
|
25278
|
+
reveal_projects: bool,
|
|
25279
|
+
breakdown_model: bool,
|
|
25280
|
+
) -> "ShareSnapshot":
|
|
25281
|
+
"""Build a ShareSnapshot for `cctally weekly`.
|
|
25282
|
+
|
|
25283
|
+
`rows` is the in-memory `BucketUsage` list produced by `_aggregate_weekly`
|
|
25284
|
+
inside `cmd_weekly`; each bucket carries `bucket` (week_start_date as
|
|
25285
|
+
"YYYY-MM-DD"), `cost_usd`, `total_tokens`, and `model_breakdowns`
|
|
25286
|
+
(list[dict] sorted by cost desc, each `{modelName, ..., cost}`).
|
|
25287
|
+
|
|
25288
|
+
`overlay` is the parallel list of `(used_pct, dollars_per_pct)` tuples
|
|
25289
|
+
computed by `cmd_weekly` from `weekly_usage_snapshots`. Either component
|
|
25290
|
+
may be `None` for a week that has no captured snapshot yet — surfaces
|
|
25291
|
+
in the snapshot row as a `0.0` PercentCell so the column stays aligned
|
|
25292
|
+
(matching the table renderer's "no data → 0%" behavior).
|
|
25293
|
+
|
|
25294
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
25295
|
+
`week_start_date` / `used_pct` / `cost_usd` / `sessions` /
|
|
25296
|
+
`model_breakdown` and a `breakdown_model: bool` derived from
|
|
25297
|
+
`args.breakdown == "model"`):
|
|
25298
|
+
|
|
25299
|
+
- Rows are `BucketUsage` dataclasses; per-week `used_pct` lives in the
|
|
25300
|
+
separate `overlay` list — neither shape matches the plan literal.
|
|
25301
|
+
- The plan's `Sessions` column has no source — `BucketUsage` carries
|
|
25302
|
+
no session count and `_aggregate_weekly` never computes one.
|
|
25303
|
+
Substituted with a `Tokens` column (`total_tokens` formatted with
|
|
25304
|
+
thousands separators).
|
|
25305
|
+
- `args.breakdown` for `cmd_weekly` is `action="store_true"` (not a
|
|
25306
|
+
`{model,project}` choice), so `breakdown_model` is just the boolean
|
|
25307
|
+
`args.breakdown` from the gate site.
|
|
25308
|
+
- `model_breakdowns` is a list-of-dicts (`modelName` / `cost`), not a
|
|
25309
|
+
`{model: cost}` mapping; we coerce to a dict before key lookup.
|
|
25310
|
+
|
|
25311
|
+
Honors `breakdown_model` by appending one `m_<model>` column per
|
|
25312
|
+
distinct model and populating `BarChart.stacks` with per-model series.
|
|
25313
|
+
All model-axis iteration uses a single sorted list (`all_model_keys`)
|
|
25314
|
+
so column / stack ordering is deterministic across runs.
|
|
25315
|
+
|
|
25316
|
+
Caller MUST pass `rows` (and `overlay` aligned to it) in chronological
|
|
25317
|
+
order so the BarChart bars line up left-to-right with time. The gate
|
|
25318
|
+
site fires BEFORE the `--order desc` reversal in `cmd_weekly`.
|
|
25319
|
+
|
|
25320
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
25321
|
+
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
25322
|
+
the gate site.
|
|
25323
|
+
"""
|
|
25324
|
+
_lib_share = _share_load_lib()
|
|
25325
|
+
columns_list: list = [
|
|
25326
|
+
_lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
|
|
25327
|
+
_lib_share.ColumnSpec(key="used", label="% Used", align="right"),
|
|
25328
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
25329
|
+
emphasis=True),
|
|
25330
|
+
_lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
25331
|
+
]
|
|
25332
|
+
# Per-row model→cost lookup (BucketUsage exposes a list-of-dicts;
|
|
25333
|
+
# collapse to dict here so per-row column population is O(1) per
|
|
25334
|
+
# model). All breakdown-aware iteration goes through `all_model_keys`
|
|
25335
|
+
# for deterministic ordering.
|
|
25336
|
+
per_row_model_costs: list[dict[str, float]] = []
|
|
25337
|
+
for r in rows:
|
|
25338
|
+
breakdowns = getattr(r, "model_breakdowns", None) or []
|
|
25339
|
+
per_row_model_costs.append({
|
|
25340
|
+
(b.get("modelName") or "—"): float(b.get("cost") or 0.0)
|
|
25341
|
+
for b in breakdowns
|
|
25342
|
+
})
|
|
25343
|
+
if breakdown_model:
|
|
25344
|
+
all_model_keys = sorted({m for d in per_row_model_costs for m in d})
|
|
25345
|
+
for m in all_model_keys:
|
|
25346
|
+
columns_list.append(_lib_share.ColumnSpec(
|
|
25347
|
+
key=f"m_{m}", label=m, align="right",
|
|
25348
|
+
))
|
|
25349
|
+
else:
|
|
25350
|
+
all_model_keys = []
|
|
25351
|
+
|
|
25352
|
+
snap_rows: list = []
|
|
25353
|
+
chart_pts: list = []
|
|
25354
|
+
stacks: dict[str, list] = {}
|
|
25355
|
+
for i, r in enumerate(rows):
|
|
25356
|
+
# `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
|
|
25357
|
+
# empty / unparseable but skip the dead `dt.date` branch.
|
|
25358
|
+
bucket = getattr(r, "bucket", None)
|
|
25359
|
+
if isinstance(bucket, str) and bucket:
|
|
25360
|
+
try:
|
|
25361
|
+
week_label = dt.date.fromisoformat(bucket).strftime("%b %d")
|
|
25362
|
+
except ValueError:
|
|
25363
|
+
week_label = bucket
|
|
25364
|
+
else:
|
|
25365
|
+
week_label = "—"
|
|
25366
|
+
cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
25367
|
+
total_tokens = int(getattr(r, "total_tokens", 0) or 0)
|
|
25368
|
+
# `used_pct` is None when the week lacks a `weekly_usage_snapshots`
|
|
25369
|
+
# row — render as em-dash to match terminal `_render_weekly_table`.
|
|
25370
|
+
# Coercing to 0.0 would conflate "no snapshot recorded" with "0%
|
|
25371
|
+
# used," same divergence the report builder fixes. cost_usd from
|
|
25372
|
+
# session_entries is genuinely 0 when there are no entries (not
|
|
25373
|
+
# missing data) so that path keeps MoneyCell(0.0).
|
|
25374
|
+
used_pct_raw = (
|
|
25375
|
+
overlay[i][0] if i < len(overlay) else None
|
|
25376
|
+
)
|
|
25377
|
+
cells = {
|
|
25378
|
+
"week": _lib_share.TextCell(week_label),
|
|
25379
|
+
"used": (
|
|
25380
|
+
_lib_share.PercentCell(float(used_pct_raw))
|
|
25381
|
+
if used_pct_raw is not None else _lib_share.TextCell("—")
|
|
25382
|
+
),
|
|
25383
|
+
"cost": _lib_share.MoneyCell(cost_usd),
|
|
25384
|
+
"tokens": _lib_share.TextCell(f"{total_tokens:,}"),
|
|
25385
|
+
}
|
|
25386
|
+
if breakdown_model:
|
|
25387
|
+
row_costs = per_row_model_costs[i]
|
|
25388
|
+
for m in all_model_keys:
|
|
25389
|
+
m_cost = float(row_costs.get(m) or 0.0)
|
|
25390
|
+
cells[f"m_{m}"] = _lib_share.MoneyCell(m_cost)
|
|
25391
|
+
stacks.setdefault(m, []).append(_lib_share.ChartPoint(
|
|
25392
|
+
x_label=week_label,
|
|
25393
|
+
x_value=float(i),
|
|
25394
|
+
y_value=m_cost,
|
|
25395
|
+
series_key=m,
|
|
25396
|
+
))
|
|
25397
|
+
snap_rows.append(_lib_share.Row(cells=cells))
|
|
25398
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25399
|
+
x_label=week_label,
|
|
25400
|
+
x_value=float(i),
|
|
25401
|
+
y_value=cost_usd,
|
|
25402
|
+
))
|
|
25403
|
+
# `BarChart.stacks` is `Mapping[str, tuple[ChartPoint, ...]] | None`
|
|
25404
|
+
# (Implementor 1's tightening); convert dict-of-lists to dict-of-tuples.
|
|
25405
|
+
stacks_immut = (
|
|
25406
|
+
{k: tuple(v) for k, v in stacks.items()} if stacks else None
|
|
25407
|
+
)
|
|
25408
|
+
chart = (
|
|
25409
|
+
_lib_share.BarChart(
|
|
25410
|
+
points=tuple(chart_pts), y_label="$", stacks=stacks_immut,
|
|
25411
|
+
)
|
|
25412
|
+
if chart_pts else None
|
|
25413
|
+
)
|
|
25414
|
+
sum_cost = sum(p.y_value for p in chart_pts)
|
|
25415
|
+
pct_values = [
|
|
25416
|
+
float(o[0]) for o in overlay
|
|
25417
|
+
if o is not None and o[0] is not None
|
|
25418
|
+
]
|
|
25419
|
+
avg_pct = (sum(pct_values) / len(pct_values)) if pct_values else 0.0
|
|
25420
|
+
peak_pct = max(pct_values, default=0.0)
|
|
25421
|
+
totals = (
|
|
25422
|
+
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
25423
|
+
_lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
|
|
25424
|
+
_lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
|
|
25425
|
+
)
|
|
25426
|
+
title = (
|
|
25427
|
+
f"Weekly usage — last {len(rows)} weeks"
|
|
25428
|
+
if rows
|
|
25429
|
+
else "Weekly usage — no data"
|
|
25430
|
+
)
|
|
25431
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
25432
|
+
subtitle = " · ".join([
|
|
25433
|
+
period_label,
|
|
25434
|
+
theme,
|
|
25435
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25436
|
+
])
|
|
25437
|
+
return _lib_share.ShareSnapshot(
|
|
25438
|
+
cmd="weekly",
|
|
25439
|
+
title=title,
|
|
25440
|
+
subtitle=subtitle,
|
|
25441
|
+
period=_lib_share.PeriodSpec(
|
|
25442
|
+
start=period_start, end=period_end,
|
|
25443
|
+
display_tz=display_tz, label=period_label,
|
|
25444
|
+
),
|
|
25445
|
+
columns=tuple(columns_list), rows=tuple(snap_rows),
|
|
25446
|
+
chart=chart, totals=totals, notes=(),
|
|
25447
|
+
generated_at=_share_now_utc(), version=version,
|
|
25448
|
+
)
|
|
25449
|
+
|
|
25450
|
+
|
|
25451
|
+
def _build_forecast_snapshot(
|
|
25452
|
+
*,
|
|
25453
|
+
week_start: dt.datetime,
|
|
25454
|
+
week_end: dt.datetime,
|
|
25455
|
+
display_tz: str,
|
|
25456
|
+
version: str,
|
|
25457
|
+
theme: str,
|
|
25458
|
+
reveal_projects: bool,
|
|
25459
|
+
actual_series: list[tuple[str, float, float]],
|
|
25460
|
+
projected_series: list[tuple[str, float, float]],
|
|
25461
|
+
current_pct: float,
|
|
25462
|
+
projected_low_pct: float,
|
|
25463
|
+
projected_high_pct: float,
|
|
25464
|
+
days_remaining: float,
|
|
25465
|
+
dollars_per_percent: float,
|
|
25466
|
+
dollars_per_percent_source: str,
|
|
25467
|
+
low_conf: bool,
|
|
25468
|
+
notes: tuple[str, ...] = (),
|
|
25469
|
+
) -> "ShareSnapshot":
|
|
25470
|
+
"""Build a ShareSnapshot for `cctally forecast`.
|
|
25471
|
+
|
|
25472
|
+
`actual_series` is a list of `(x_label, x_value, y_value)` tuples drawn
|
|
25473
|
+
from `weekly_usage_snapshots` for the current week — each sample's
|
|
25474
|
+
`captured_at_utc` is the x_label (formatted compactly), `x_value` is
|
|
25475
|
+
elapsed-hours-since-week-start (a monotonic float so the LineChart
|
|
25476
|
+
renders left→right), and `y_value` is `weekly_percent` at that capture.
|
|
25477
|
+
|
|
25478
|
+
`projected_series` is a parallel list of `(x_label, x_value, y_value)`
|
|
25479
|
+
tuples for the projection ray — the simplest form is a 2-point line
|
|
25480
|
+
from `(now, current_pct)` to `(week_end, projected_eow_pct)`. The
|
|
25481
|
+
renderer treats it as a `multi_series` overlay on top of the actual line.
|
|
25482
|
+
|
|
25483
|
+
Deviations from the plan sketch (which assumed a single
|
|
25484
|
+
`_compute_forecast_data(args) -> dict` helper and `dpp_week_avg` /
|
|
25485
|
+
`dpp_24h` as separate columns):
|
|
25486
|
+
|
|
25487
|
+
- `cmd_forecast` already exposes the data as `ForecastOutput` (which
|
|
25488
|
+
wraps `ForecastInputs`). No helper extraction was needed; we pass
|
|
25489
|
+
the actual scalars in directly.
|
|
25490
|
+
- `ForecastInputs` carries a single `dollars_per_percent` value plus
|
|
25491
|
+
a `dollars_per_percent_source` enum (`this_week` /
|
|
25492
|
+
`trailing_4wk_median` / `this_week_sparse`); there is no separate
|
|
25493
|
+
`dpp_week_avg` and `dpp_24h`. The table renders one $/1% row with
|
|
25494
|
+
the source as a paren suffix in the metric cell.
|
|
25495
|
+
- The plan's single `projected_eow_pct` is split into a low/high
|
|
25496
|
+
range (matching `--render-forecast-terminal`'s "Forecast 80–95%"
|
|
25497
|
+
band). The table shows both ends; the projected_series ray uses
|
|
25498
|
+
the high end so the overlay aligns with the conservative budget
|
|
25499
|
+
consumers expect from the chart.
|
|
25500
|
+
|
|
25501
|
+
Reference lines at 90%/100% are LineChart-stable across all samples;
|
|
25502
|
+
severities `warn` (90%) and `alarm` (100%) drive the renderer's
|
|
25503
|
+
color mapping.
|
|
25504
|
+
|
|
25505
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
25506
|
+
builder owns the canonical subtitle shape — no post-build re-stamp
|
|
25507
|
+
at the gate site.
|
|
25508
|
+
|
|
25509
|
+
`notes`, when non-empty, overrides the auto-emitted "LOW CONF — data
|
|
25510
|
+
thin" note. The empty-data fast-path passes a clearer "no snapshots
|
|
25511
|
+
recorded" note so the artifact says what's actually wrong; the
|
|
25512
|
+
confidence-thin terminal path passes nothing and falls back to the
|
|
25513
|
+
auto LOW CONF banner.
|
|
25514
|
+
"""
|
|
25515
|
+
_lib_share = _share_load_lib()
|
|
25516
|
+
actual_pts = tuple(
|
|
25517
|
+
_lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
|
|
25518
|
+
for lbl, xv, yv in actual_series
|
|
25519
|
+
)
|
|
25520
|
+
projected_pts = tuple(
|
|
25521
|
+
_lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
|
|
25522
|
+
for lbl, xv, yv in projected_series
|
|
25523
|
+
)
|
|
25524
|
+
chart = (
|
|
25525
|
+
_lib_share.LineChart(
|
|
25526
|
+
points=actual_pts,
|
|
25527
|
+
y_label="cumulative %",
|
|
25528
|
+
reference_lines=(
|
|
25529
|
+
(90.0, "90%", "warn"),
|
|
25530
|
+
(100.0, "100%", "alarm"),
|
|
25531
|
+
),
|
|
25532
|
+
multi_series={"projected": projected_pts} if projected_pts else None,
|
|
25533
|
+
)
|
|
25534
|
+
if actual_pts else None
|
|
25535
|
+
)
|
|
25536
|
+
|
|
25537
|
+
columns = (
|
|
25538
|
+
_lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
|
|
25539
|
+
_lib_share.ColumnSpec(key="value", label="Value", align="right",
|
|
25540
|
+
emphasis=True),
|
|
25541
|
+
)
|
|
25542
|
+
# Render the projected band as "low-high%" so a single PercentCell
|
|
25543
|
+
# carries the two-rate forecast spread. When the rates collapse to a
|
|
25544
|
+
# single value (no recent-24h sample), low == high.
|
|
25545
|
+
# 0.05 threshold: below .1f display precision — tighter spreads would
|
|
25546
|
+
# render as identical decimals, so collapse to a single value.
|
|
25547
|
+
if abs(projected_high_pct - projected_low_pct) < 0.05:
|
|
25548
|
+
projected_text = f"{projected_high_pct:.1f}%"
|
|
25549
|
+
else:
|
|
25550
|
+
projected_text = (
|
|
25551
|
+
f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
|
|
25552
|
+
)
|
|
25553
|
+
dpp_source_label = dollars_per_percent_source.replace("_", " ")
|
|
25554
|
+
snap_rows = (
|
|
25555
|
+
_lib_share.Row(cells={
|
|
25556
|
+
"metric": _lib_share.TextCell("Current %"),
|
|
25557
|
+
"value": _lib_share.PercentCell(float(current_pct)),
|
|
25558
|
+
}),
|
|
25559
|
+
_lib_share.Row(cells={
|
|
25560
|
+
"metric": _lib_share.TextCell("Projected end-of-week %"),
|
|
25561
|
+
"value": _lib_share.TextCell(projected_text),
|
|
25562
|
+
}),
|
|
25563
|
+
_lib_share.Row(cells={
|
|
25564
|
+
"metric": _lib_share.TextCell("Days remaining"),
|
|
25565
|
+
"value": _lib_share.TextCell(f"{days_remaining:.1f}"),
|
|
25566
|
+
}),
|
|
25567
|
+
_lib_share.Row(cells={
|
|
25568
|
+
"metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
|
|
25569
|
+
"value": _lib_share.MoneyCell(float(dollars_per_percent)),
|
|
25570
|
+
}),
|
|
25571
|
+
)
|
|
25572
|
+
# Caller-provided `notes` (e.g., empty-data path's clearer message)
|
|
25573
|
+
# take precedence over the auto LOW CONF banner. Sibling builders
|
|
25574
|
+
# don't expose this knob; forecast does because its empty-state and
|
|
25575
|
+
# thin-confidence states need different copy.
|
|
25576
|
+
final_notes = notes if notes else (
|
|
25577
|
+
("LOW CONF — data thin",) if low_conf else ()
|
|
25578
|
+
)
|
|
25579
|
+
if actual_pts:
|
|
25580
|
+
title = f"Forecast — week of {week_start.strftime('%b %d')}"
|
|
25581
|
+
else:
|
|
25582
|
+
title = "Forecast — no data"
|
|
25583
|
+
# Reuse the shared period-label helper so forecast's subtitle period
|
|
25584
|
+
# format matches sibling builders (cmd_daily / cmd_project / etc.).
|
|
25585
|
+
period_label = _share_period_label(week_start, week_end, display_tz)
|
|
25586
|
+
subtitle = " · ".join([
|
|
25587
|
+
period_label,
|
|
25588
|
+
theme,
|
|
25589
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25590
|
+
])
|
|
25591
|
+
return _lib_share.ShareSnapshot(
|
|
25592
|
+
cmd="forecast",
|
|
25593
|
+
title=title,
|
|
25594
|
+
subtitle=subtitle,
|
|
25595
|
+
period=_lib_share.PeriodSpec(
|
|
25596
|
+
start=week_start, end=week_end,
|
|
25597
|
+
display_tz=display_tz, label=period_label,
|
|
25598
|
+
),
|
|
25599
|
+
columns=columns, rows=snap_rows,
|
|
25600
|
+
chart=chart, totals=(), notes=final_notes,
|
|
25601
|
+
generated_at=_share_now_utc(), version=version,
|
|
25602
|
+
)
|
|
25603
|
+
|
|
25604
|
+
|
|
25605
|
+
def _build_project_snapshot(
|
|
25606
|
+
rows: list[dict],
|
|
25607
|
+
*,
|
|
25608
|
+
period_start: dt.datetime,
|
|
25609
|
+
period_end: dt.datetime,
|
|
25610
|
+
display_tz: str,
|
|
25611
|
+
version: str,
|
|
25612
|
+
theme: str,
|
|
25613
|
+
reveal_projects: bool,
|
|
25614
|
+
) -> "ShareSnapshot":
|
|
25615
|
+
"""Build a ShareSnapshot for `cctally project`.
|
|
25616
|
+
|
|
25617
|
+
`rows` is the in-memory per-project aggregate list produced inside
|
|
25618
|
+
`cmd_project` (`project_rows.values()` post-sort). Each row is a
|
|
25619
|
+
dict with: `key` (a `ProjectKey` carrying `display_key` /
|
|
25620
|
+
`bucket_path`), `cost_usd`, `attributed_pct` (`float | None`),
|
|
25621
|
+
`sessions` (a `set` of session-IDs).
|
|
25622
|
+
|
|
25623
|
+
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
25624
|
+
`ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
|
|
25625
|
+
which is the project axis on a HorizontalBarChart) with the REAL
|
|
25626
|
+
`display_key`. The `_share_render_and_emit` wrapper then runs
|
|
25627
|
+
`_lib_share._scrub` BEFORE rendering — that's the single chokepoint
|
|
25628
|
+
that rewrites every project label to `project-1` / `project-2` /
|
|
25629
|
+
... unless `--reveal-projects` is passed. The Section 8.4 canary
|
|
25630
|
+
test (`test_anonymized_output_contains_zero_original_tokens`) and
|
|
25631
|
+
the wrapper-level regression
|
|
25632
|
+
(`test_share_render_and_emit_scrubs_project_labels`) both anchor
|
|
25633
|
+
this contract.
|
|
25634
|
+
|
|
25635
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
25636
|
+
`project` / `cost_usd` / `used_pct` / `sessions`):
|
|
25637
|
+
|
|
25638
|
+
- Rows are dicts whose `key` field is a `ProjectKey` dataclass; the
|
|
25639
|
+
project label comes from `key.display_key`. The plan's `project`
|
|
25640
|
+
key does not exist on the actual `cmd_project` data shape.
|
|
25641
|
+
- `attributed_pct` may be `None` for projects whose contributing
|
|
25642
|
+
weeks all lacked a `weekly_usage_snapshots` row; the table renders
|
|
25643
|
+
that as em-dash (parity with terminal `_render_project_table`).
|
|
25644
|
+
- Sessions is a `set`; the cell carries its `len(...)` as text.
|
|
25645
|
+
|
|
25646
|
+
`HorizontalBarChart.cap=12` matches the plan; when more than 12
|
|
25647
|
+
projects exist, a note clarifies that the table includes all rows
|
|
25648
|
+
while the chart shows only the top 12 by cost.
|
|
25649
|
+
|
|
25650
|
+
Caller MUST pass `rows` already sorted in the desired order
|
|
25651
|
+
(cmd_project honors `--sort` / `--order` upstream). The builder
|
|
25652
|
+
preserves caller order for the table — terminal / JSON / share
|
|
25653
|
+
artifacts all show the same row ordering. Internally the builder
|
|
25654
|
+
ALSO computes a descending-cost copy that drives the HBar chart
|
|
25655
|
+
and the basename-disambiguation rank (both must match
|
|
25656
|
+
`_build_anon_mapping`'s descending-cost sort so `project-1` stays
|
|
25657
|
+
glued to the highest-cost bar regardless of `--sort`). Anonymization
|
|
25658
|
+
is row-identity based (`id(r)` → augmented label), not position
|
|
25659
|
+
based, so the table sees the same disambiguated label as the chart.
|
|
25660
|
+
|
|
25661
|
+
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
25662
|
+
builder owns the canonical subtitle shape — no post-build re-stamp
|
|
25663
|
+
at the gate site.
|
|
25664
|
+
"""
|
|
25665
|
+
_lib_share = _share_load_lib()
|
|
25666
|
+
columns = (
|
|
25667
|
+
_lib_share.ColumnSpec(key="project", label="Project", align="left"),
|
|
25668
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
25669
|
+
emphasis=True),
|
|
25670
|
+
_lib_share.ColumnSpec(key="used", label="% Used", align="right"),
|
|
25671
|
+
_lib_share.ColumnSpec(key="sessions", label="Sessions", align="right"),
|
|
25672
|
+
)
|
|
25673
|
+
# Two orderings — same rows, different consumers:
|
|
25674
|
+
#
|
|
25675
|
+
# * `rows` (caller order) drives the table. `cmd_project` upstream
|
|
25676
|
+
# has already applied `--sort` / `--order`, so the share artifact's
|
|
25677
|
+
# table matches terminal / JSON output for any of `--sort cost`,
|
|
25678
|
+
# `--sort name`, `--sort sessions` × `--order asc|desc`.
|
|
25679
|
+
#
|
|
25680
|
+
# * `cost_sorted_rows` (descending cost) drives the HBar chart and
|
|
25681
|
+
# the basename-disambiguation rank — both must align with
|
|
25682
|
+
# `_build_anon_mapping`'s descending-cost sort so `project-1` stays
|
|
25683
|
+
# glued to the highest-cost bar regardless of `--sort` choice.
|
|
25684
|
+
cost_sorted_rows = sorted(
|
|
25685
|
+
rows, key=lambda r: -float(r.get("cost_usd") or 0.0)
|
|
25686
|
+
)
|
|
25687
|
+
# Basename-collision disambiguation: mirrors `_render_project_table`'s
|
|
25688
|
+
# terminal logic. Computed on cost_sorted_rows; mapped back to row
|
|
25689
|
+
# identity so the caller-ordered table picks up the same augmented
|
|
25690
|
+
# label as the chart (anonymization is row-identity based, not
|
|
25691
|
+
# position based). Without disambiguation, two `app` projects under
|
|
25692
|
+
# different parent dirs collapse to ONE anonymous `project-N` after
|
|
25693
|
+
# scrub — losing both privacy uniqueness and chart rank meaning.
|
|
25694
|
+
augmented_by_index = _project_disambiguate_labels(cost_sorted_rows)
|
|
25695
|
+
augmented_by_row_id: dict[int, str] = {
|
|
25696
|
+
id(cost_sorted_rows[idx]): label
|
|
25697
|
+
for idx, label in augmented_by_index.items()
|
|
25698
|
+
}
|
|
25699
|
+
|
|
25700
|
+
def _proj_label_for(r: dict) -> str:
|
|
25701
|
+
bare = getattr(r.get("key"), "display_key", None) or "(unknown)"
|
|
25702
|
+
return augmented_by_row_id.get(id(r), bare)
|
|
25703
|
+
|
|
25704
|
+
# Table rows in CALLER order (--sort / --order parity).
|
|
25705
|
+
snap_rows: list = []
|
|
25706
|
+
for r in rows:
|
|
25707
|
+
proj_label = _proj_label_for(r)
|
|
25708
|
+
cost = float(r.get("cost_usd") or 0.0)
|
|
25709
|
+
attr_pct = r.get("attributed_pct")
|
|
25710
|
+
sessions = r.get("sessions")
|
|
25711
|
+
sessions_count = len(sessions) if sessions is not None else 0
|
|
25712
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
25713
|
+
"project": _lib_share.ProjectCell(proj_label),
|
|
25714
|
+
"cost": _lib_share.MoneyCell(cost),
|
|
25715
|
+
# Preserve None vs 0.0 — terminal renders missing as em-dash.
|
|
25716
|
+
# Coercing None -> 0.0 would conflate "no usage snapshot for
|
|
25717
|
+
# any week this project touched" with "0% attributed."
|
|
25718
|
+
"used": (
|
|
25719
|
+
_lib_share.PercentCell(float(attr_pct))
|
|
25720
|
+
if attr_pct is not None else _lib_share.TextCell("—")
|
|
25721
|
+
),
|
|
25722
|
+
"sessions": _lib_share.TextCell(str(sessions_count)),
|
|
25723
|
+
}))
|
|
25724
|
+
|
|
25725
|
+
# Chart points in COST-SORTED order (HBar shows top-N by cost).
|
|
25726
|
+
chart_pts: list = []
|
|
25727
|
+
for r in cost_sorted_rows:
|
|
25728
|
+
proj_label = _proj_label_for(r)
|
|
25729
|
+
cost = float(r.get("cost_usd") or 0.0)
|
|
25730
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25731
|
+
x_label=proj_label,
|
|
25732
|
+
x_value=cost,
|
|
25733
|
+
y_value=cost,
|
|
25734
|
+
project_label=proj_label,
|
|
25735
|
+
))
|
|
25736
|
+
chart = (
|
|
25737
|
+
_lib_share.HorizontalBarChart(
|
|
25738
|
+
points=tuple(chart_pts), x_label="$", cap=12,
|
|
25739
|
+
)
|
|
25740
|
+
if chart_pts else None
|
|
25741
|
+
)
|
|
25742
|
+
notes: tuple[str, ...] = ()
|
|
25743
|
+
if chart is not None and len(chart_pts) > 12:
|
|
25744
|
+
notes = (
|
|
25745
|
+
f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
|
|
25746
|
+
)
|
|
25747
|
+
sum_cost = sum(p.y_value for p in chart_pts)
|
|
25748
|
+
totals = (
|
|
25749
|
+
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
25750
|
+
_lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
|
|
25751
|
+
)
|
|
25752
|
+
if rows:
|
|
25753
|
+
title = (
|
|
25754
|
+
f"Per-project usage — {period_start.strftime('%b %d')} → "
|
|
25755
|
+
f"{period_end.strftime('%b %d')}"
|
|
25756
|
+
)
|
|
25757
|
+
else:
|
|
25758
|
+
title = "Per-project usage — no data"
|
|
25759
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
25760
|
+
subtitle = " · ".join([
|
|
25761
|
+
period_label,
|
|
25762
|
+
theme,
|
|
25763
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25764
|
+
])
|
|
25765
|
+
return _lib_share.ShareSnapshot(
|
|
25766
|
+
cmd="project",
|
|
25767
|
+
title=title,
|
|
25768
|
+
subtitle=subtitle,
|
|
25769
|
+
period=_lib_share.PeriodSpec(
|
|
25770
|
+
start=period_start, end=period_end,
|
|
25771
|
+
display_tz=display_tz, label=period_label,
|
|
25772
|
+
),
|
|
25773
|
+
columns=columns, rows=tuple(snap_rows),
|
|
25774
|
+
chart=chart, totals=totals, notes=notes,
|
|
25775
|
+
generated_at=_share_now_utc(), version=version,
|
|
25776
|
+
)
|
|
25777
|
+
|
|
25778
|
+
|
|
25779
|
+
def _build_five_hour_blocks_snapshot(
|
|
25780
|
+
rows: list[dict],
|
|
25781
|
+
*,
|
|
25782
|
+
period_start: dt.datetime,
|
|
25783
|
+
period_end: dt.datetime,
|
|
25784
|
+
display_tz: str,
|
|
25785
|
+
version: str,
|
|
25786
|
+
theme: str,
|
|
25787
|
+
reveal_projects: bool,
|
|
25788
|
+
tz: "ZoneInfo | None",
|
|
25789
|
+
) -> "ShareSnapshot":
|
|
25790
|
+
"""Build a ShareSnapshot for `cctally five-hour-blocks`.
|
|
25791
|
+
|
|
25792
|
+
`rows` is the list of per-block dicts produced inside
|
|
25793
|
+
`cmd_five_hour_blocks` (sqlite Row converted to dict, with the
|
|
25794
|
+
`__is_active` side-channel attached). Schema fields used:
|
|
25795
|
+
`block_start_at` (ISO timestamp), `total_cost_usd`,
|
|
25796
|
+
`final_five_hour_percent`, `crossed_seven_day_reset` (0/1 int),
|
|
25797
|
+
`seven_day_pct_at_block_start`, `seven_day_pct_at_block_end`, plus
|
|
25798
|
+
the synthetic `__is_active` flag.
|
|
25799
|
+
|
|
25800
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
25801
|
+
`block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
|
|
25802
|
+
`cross_reset`):
|
|
25803
|
+
|
|
25804
|
+
- Rows are sqlite-Row-derived dicts with snake_case schema column
|
|
25805
|
+
names — `block_start_at`, `total_cost_usd`,
|
|
25806
|
+
`final_five_hour_percent`, `crossed_seven_day_reset`. The plan
|
|
25807
|
+
keys `block_start` / `cost_usd` / `used_pct_5h` / `cross_reset`
|
|
25808
|
+
do not exist on the actual data shape.
|
|
25809
|
+
- `top_model` does not live on the `five_hour_blocks` row at all;
|
|
25810
|
+
`_load_breakdown` would have to be invoked per-block to derive
|
|
25811
|
+
it. Per share-spec convention (matches cmd_daily / cmd_monthly),
|
|
25812
|
+
the `--breakdown` flag is a no-op under `--format` and the
|
|
25813
|
+
headline snapshot omits the per-model "top model" column.
|
|
25814
|
+
- `crossed_seven_day_reset` is an INTEGER 0/1 (sqlite); coerce to
|
|
25815
|
+
`bool` for cell formatting.
|
|
25816
|
+
|
|
25817
|
+
Cross-reset markers (spec §6.5):
|
|
25818
|
+
- `chart_pts` — `▲` (U+25B2) prefix in `x_label` so the SVG
|
|
25819
|
+
x-axis label visually flags the crossed-reset blocks.
|
|
25820
|
+
- `snap_rows` — `⚡` (U+26A1) glyph in the `cross_reset` cell
|
|
25821
|
+
text so the markdown / HTML table cell carries the same
|
|
25822
|
+
signal. The two glyphs are distinct (triangle for chart axis,
|
|
25823
|
+
bolt for table cell) so the legend reads correctly in either
|
|
25824
|
+
surface.
|
|
25825
|
+
|
|
25826
|
+
`theme` and `reveal_projects` flow into the subtitle directly so
|
|
25827
|
+
the builder owns the canonical subtitle shape — no post-build
|
|
25828
|
+
re-stamp at the gate site.
|
|
25829
|
+
|
|
25830
|
+
Caller MUST pass `rows` already in the desired chronological order
|
|
25831
|
+
(cmd_five_hour_blocks pulls newest-first; we reverse here so the
|
|
25832
|
+
BarChart bars line up oldest→newest left-to-right). Tabular row
|
|
25833
|
+
order in the snapshot is irrelevant because the snapshot is what
|
|
25834
|
+
gets rendered (the gate site short-circuits the table renderer).
|
|
25835
|
+
"""
|
|
25836
|
+
_lib_share = _share_load_lib()
|
|
25837
|
+
columns = (
|
|
25838
|
+
_lib_share.ColumnSpec(key="block_start", label="Block Start",
|
|
25839
|
+
align="left"),
|
|
25840
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
25841
|
+
emphasis=True),
|
|
25842
|
+
_lib_share.ColumnSpec(key="used_pct", label="5h %",
|
|
25843
|
+
align="right"),
|
|
25844
|
+
_lib_share.ColumnSpec(key="cross_reset", label="Reset",
|
|
25845
|
+
align="left"),
|
|
25846
|
+
)
|
|
25847
|
+
# Reverse so BarChart x-axis runs oldest→newest (cmd_five_hour_blocks
|
|
25848
|
+
# produces newest-first DESC); table-row order in the snapshot tracks
|
|
25849
|
+
# chart order so consumer expectations align.
|
|
25850
|
+
chrono_rows = list(reversed(rows))
|
|
25851
|
+
snap_rows: list = []
|
|
25852
|
+
chart_pts: list = []
|
|
25853
|
+
for i, r in enumerate(chrono_rows):
|
|
25854
|
+
block_iso = r.get("block_start_at") or ""
|
|
25855
|
+
# Compact label respecting --tz; previously hard-coded to UTC
|
|
25856
|
+
# (parsed.strftime renders the wall-clock IN the parsed tz, and
|
|
25857
|
+
# `parsed` is tz-aware UTC after fromisoformat). UTC-vs-display_tz
|
|
25858
|
+
# is orthogonal to the SVG x-axis width budget — both render at
|
|
25859
|
+
# the same character count. Route through `format_display_dt`
|
|
25860
|
+
# with suffix=False to satisfy the chokepoint rule while keeping
|
|
25861
|
+
# the bar label compact (the subtitle's period_label already
|
|
25862
|
+
# carries the active tz).
|
|
25863
|
+
try:
|
|
25864
|
+
parsed = dt.datetime.fromisoformat(
|
|
25865
|
+
block_iso.replace("Z", "+00:00")
|
|
25866
|
+
)
|
|
25867
|
+
block_lbl = format_display_dt(
|
|
25868
|
+
parsed, tz, fmt="%b %d %H:%M", suffix=False,
|
|
25869
|
+
)
|
|
25870
|
+
except (ValueError, AttributeError):
|
|
25871
|
+
block_lbl = str(block_iso)
|
|
25872
|
+
cost_usd = float(r.get("total_cost_usd") or 0.0)
|
|
25873
|
+
used_pct = float(r.get("final_five_hour_percent") or 0.0)
|
|
25874
|
+
crossed = bool(r.get("crossed_seven_day_reset"))
|
|
25875
|
+
cell_text = "⚡" if crossed else "—"
|
|
25876
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
25877
|
+
"block_start": _lib_share.TextCell(block_lbl),
|
|
25878
|
+
"cost": _lib_share.MoneyCell(cost_usd),
|
|
25879
|
+
"used_pct": _lib_share.PercentCell(used_pct),
|
|
25880
|
+
"cross_reset": _lib_share.TextCell(cell_text),
|
|
25881
|
+
}))
|
|
25882
|
+
x_label = f"▲ {block_lbl}" if crossed else block_lbl
|
|
25883
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
25884
|
+
x_label=x_label,
|
|
25885
|
+
x_value=float(i),
|
|
25886
|
+
y_value=cost_usd,
|
|
25887
|
+
))
|
|
25888
|
+
chart = (
|
|
25889
|
+
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
25890
|
+
if chart_pts else None
|
|
25891
|
+
)
|
|
25892
|
+
sum_cost = sum(p.y_value for p in chart_pts)
|
|
25893
|
+
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
25894
|
+
crossed_count = sum(
|
|
25895
|
+
1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
|
|
25896
|
+
)
|
|
25897
|
+
totals_list = [
|
|
25898
|
+
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
25899
|
+
_lib_share.Totalled(label="Blocks", value=str(len(chart_pts))),
|
|
25900
|
+
_lib_share.Totalled(label="Avg / block", value=f"${avg_cost:,.2f}"),
|
|
25901
|
+
]
|
|
25902
|
+
if crossed_count:
|
|
25903
|
+
totals_list.append(_lib_share.Totalled(
|
|
25904
|
+
label="Crossed reset", value=str(crossed_count),
|
|
25905
|
+
))
|
|
25906
|
+
totals = tuple(totals_list)
|
|
25907
|
+
notes: tuple[str, ...] = ()
|
|
25908
|
+
if crossed_count:
|
|
25909
|
+
notes = (
|
|
25910
|
+
"▲ / ⚡ marks blocks that crossed the weekly reset boundary.",
|
|
25911
|
+
)
|
|
25912
|
+
if rows:
|
|
25913
|
+
title = f"5-hour blocks — last {len(rows)} blocks"
|
|
25914
|
+
else:
|
|
25915
|
+
title = "5-hour blocks — no data"
|
|
25916
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
25917
|
+
subtitle = " · ".join([
|
|
25918
|
+
period_label,
|
|
25919
|
+
theme,
|
|
25920
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
25921
|
+
])
|
|
25922
|
+
return _lib_share.ShareSnapshot(
|
|
25923
|
+
cmd="five-hour-blocks",
|
|
25924
|
+
title=title,
|
|
25925
|
+
subtitle=subtitle,
|
|
25926
|
+
period=_lib_share.PeriodSpec(
|
|
25927
|
+
start=period_start, end=period_end,
|
|
25928
|
+
display_tz=display_tz, label=period_label,
|
|
25929
|
+
),
|
|
25930
|
+
columns=columns, rows=tuple(snap_rows),
|
|
25931
|
+
chart=chart, totals=totals, notes=notes,
|
|
25932
|
+
generated_at=_share_now_utc(), version=version,
|
|
25933
|
+
)
|
|
25934
|
+
|
|
25935
|
+
|
|
25936
|
+
def _session_disambiguate_labels(
|
|
25937
|
+
sessions: list["ClaudeSessionUsage"],
|
|
25938
|
+
) -> dict[int, str]:
|
|
25939
|
+
"""Return ``{session_index: disambiguated_label}`` for sessions whose
|
|
25940
|
+
bare ``project_path`` basename collides with another session's.
|
|
25941
|
+
|
|
25942
|
+
Session-specific sibling of ``_project_disambiguate_labels`` (which
|
|
25943
|
+
operates over project rollup rows whose `key` is a ``ProjectKey``).
|
|
25944
|
+
Sessions carry only a `project_path` string — we derive the
|
|
25945
|
+
basename, count collisions, and append a parent-dir suffix
|
|
25946
|
+
``" (parent)"`` to colliding rows so the post-scrub anonymization
|
|
25947
|
+
still produces unique anonymous labels (otherwise two `app/`
|
|
25948
|
+
sessions under different parents collapse to a single
|
|
25949
|
+
``project-N``, breaking both privacy uniqueness and the chart's
|
|
25950
|
+
visual rank meaning).
|
|
25951
|
+
|
|
25952
|
+
Sessions without collisions are absent from the returned dict;
|
|
25953
|
+
callers fall back to the bare basename.
|
|
25954
|
+
"""
|
|
25955
|
+
basenames: list[str] = []
|
|
25956
|
+
for s in sessions:
|
|
25957
|
+
path = s.project_path or ""
|
|
25958
|
+
basenames.append(os.path.basename(path) or path or "(unknown)")
|
|
25959
|
+
counts: dict[str, int] = {}
|
|
25960
|
+
for bn in basenames:
|
|
25961
|
+
counts[bn] = counts.get(bn, 0) + 1
|
|
25962
|
+
augmented: dict[int, str] = {}
|
|
25963
|
+
for idx, s in enumerate(sessions):
|
|
25964
|
+
bn = basenames[idx]
|
|
25965
|
+
# Skip suffixing the literal "(unknown)" bare label even on
|
|
25966
|
+
# collision: `_build_anon_mapping` literal-passthrough-protects
|
|
25967
|
+
# exact "(unknown)" only — a suffixed form like "(unknown) (/)"
|
|
25968
|
+
# would be mapped to a regular `project-N` slot, losing the
|
|
25969
|
+
# (unknown) semantic in the anonymized output.
|
|
25970
|
+
if counts[bn] > 1 and bn != "(unknown)":
|
|
25971
|
+
path = s.project_path or ""
|
|
25972
|
+
parent = os.path.basename(os.path.dirname(path)) or "/"
|
|
25973
|
+
augmented[idx] = f"{bn} ({parent})"
|
|
25974
|
+
return augmented
|
|
25975
|
+
|
|
25976
|
+
|
|
25977
|
+
def _build_session_snapshot(
|
|
25978
|
+
sessions: list["ClaudeSessionUsage"],
|
|
25979
|
+
*,
|
|
25980
|
+
period_start: dt.datetime,
|
|
25981
|
+
period_end: dt.datetime,
|
|
25982
|
+
display_tz: str,
|
|
25983
|
+
version: str,
|
|
25984
|
+
theme: str,
|
|
25985
|
+
reveal_projects: bool,
|
|
25986
|
+
top_n: int | None,
|
|
25987
|
+
tz: "ZoneInfo | None",
|
|
25988
|
+
) -> "ShareSnapshot":
|
|
25989
|
+
"""Build a ShareSnapshot for `cctally session`.
|
|
25990
|
+
|
|
25991
|
+
`sessions` is the in-memory `ClaudeSessionUsage` list produced by
|
|
25992
|
+
`_aggregate_claude_sessions` inside `cmd_session`. Each session has:
|
|
25993
|
+
`session_id` (UUID), `project_path` (filesystem path), `cost_usd`,
|
|
25994
|
+
`last_activity` (`dt.datetime`), `models` (first-seen-order
|
|
25995
|
+
`list[str]`), and the token aggregates.
|
|
25996
|
+
|
|
25997
|
+
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
25998
|
+
`ProjectCell.label`, `ChartPoint.project_label`, and
|
|
25999
|
+
`ChartPoint.x_label` with the REAL `project_path` basename. The
|
|
26000
|
+
`_share_render_and_emit` wrapper runs `_lib_share._scrub` BEFORE
|
|
26001
|
+
rendering — that's the single chokepoint that rewrites every
|
|
26002
|
+
project label to `project-1` / `project-2` / ... unless
|
|
26003
|
+
`--reveal-projects` is passed.
|
|
26004
|
+
|
|
26005
|
+
Deviations from the plan sketch (which assumed dict rows with keys
|
|
26006
|
+
`session_id` / `started_at` / `project_path` / `cost_usd` /
|
|
26007
|
+
`models`):
|
|
26008
|
+
|
|
26009
|
+
- Sessions are `ClaudeSessionUsage` dataclasses; we read fields by
|
|
26010
|
+
attribute. `last_activity` is the canonical timestamp (no
|
|
26011
|
+
`started_at` field — sessions span a window via
|
|
26012
|
+
`first_activity` → `last_activity`).
|
|
26013
|
+
- The `project_path` column's basename can collide across two
|
|
26014
|
+
different parent dirs. We use the session-specific
|
|
26015
|
+
`_session_disambiguate_labels` helper (sibling of
|
|
26016
|
+
`_project_disambiguate_labels`, which expects `ProjectKey` rows
|
|
26017
|
+
not present on session data) to suffix `" (parent)"` on
|
|
26018
|
+
collisions before the scrubber runs.
|
|
26019
|
+
|
|
26020
|
+
Caller MUST pass `sessions` already sorted in the desired order;
|
|
26021
|
+
the builder re-sorts internally by descending cost so the chart's
|
|
26022
|
+
HBar bars rank consistently with the anonymization-mapping
|
|
26023
|
+
(`_build_anon_mapping` also sorts by descending cost) — keeping
|
|
26024
|
+
`project-1` aligned with the highest-cost bar in the chart even
|
|
26025
|
+
when the user asked for `--order asc`.
|
|
26026
|
+
|
|
26027
|
+
`top_n`, when set (must be `>= 1`; caller validates), truncates
|
|
26028
|
+
BOTH the table rows and the chart points to the top-N by cost.
|
|
26029
|
+
The title shifts to `"Top N sessions"` whenever `top_n` actually
|
|
26030
|
+
truncated (so users know rows were dropped). When more rows exist
|
|
26031
|
+
than the chart cap (15) but `top_n` is None or `>= len(sessions)`,
|
|
26032
|
+
the table includes all rows while the chart shows the top 15 by
|
|
26033
|
+
cost (a note clarifies).
|
|
26034
|
+
|
|
26035
|
+
`theme` and `reveal_projects` flow into the subtitle directly so
|
|
26036
|
+
the builder owns the canonical subtitle shape — no post-build
|
|
26037
|
+
re-stamp at the gate site.
|
|
26038
|
+
"""
|
|
26039
|
+
_lib_share = _share_load_lib()
|
|
26040
|
+
columns = (
|
|
26041
|
+
_lib_share.ColumnSpec(key="session", label="Session", align="left"),
|
|
26042
|
+
_lib_share.ColumnSpec(key="project", label="Project", align="left"),
|
|
26043
|
+
_lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
|
|
26044
|
+
emphasis=True),
|
|
26045
|
+
_lib_share.ColumnSpec(key="last_activity", label="Last Activity",
|
|
26046
|
+
align="left"),
|
|
26047
|
+
_lib_share.ColumnSpec(key="models", label="Models", align="left"),
|
|
26048
|
+
)
|
|
26049
|
+
# Sort by descending cost so the snapshot's chart-order matches the
|
|
26050
|
+
# `_build_anon_mapping` sort key (also descending cost).
|
|
26051
|
+
sorted_sessions = sorted(
|
|
26052
|
+
sessions, key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0)
|
|
26053
|
+
)
|
|
26054
|
+
# Apply --top-n truncation (caller validated >= 1). Truncation status
|
|
26055
|
+
# gates the title shape below.
|
|
26056
|
+
truncated = (
|
|
26057
|
+
top_n is not None and top_n < len(sorted_sessions)
|
|
26058
|
+
)
|
|
26059
|
+
if top_n is not None:
|
|
26060
|
+
sorted_sessions = sorted_sessions[:top_n]
|
|
26061
|
+
# Basename-collision disambiguation: session-specific sibling of
|
|
26062
|
+
# `_project_disambiguate_labels`. Without this, two `app/` sessions
|
|
26063
|
+
# under different parents collapse to a single `project-N` after
|
|
26064
|
+
# scrub — losing both privacy uniqueness and chart rank meaning.
|
|
26065
|
+
augmented = _session_disambiguate_labels(sorted_sessions)
|
|
26066
|
+
snap_rows: list = []
|
|
26067
|
+
chart_pts: list = []
|
|
26068
|
+
for idx, s in enumerate(sorted_sessions):
|
|
26069
|
+
bare_label = (
|
|
26070
|
+
os.path.basename(s.project_path or "")
|
|
26071
|
+
or s.project_path
|
|
26072
|
+
or "(unknown)"
|
|
26073
|
+
)
|
|
26074
|
+
proj_label = augmented.get(idx, bare_label)
|
|
26075
|
+
cost_usd = float(getattr(s, "cost_usd", 0.0) or 0.0)
|
|
26076
|
+
sid_short = (s.session_id[:8] if s.session_id else "—") or "—"
|
|
26077
|
+
# Datetime chokepoint rule: route human-displayed timestamps
|
|
26078
|
+
# through `format_display_dt` so `--tz` is honored (was
|
|
26079
|
+
# `.astimezone()` which used host-local regardless of `--tz`).
|
|
26080
|
+
# `suffix=False` keeps the cell width tight — the subtitle's
|
|
26081
|
+
# period_label already carries the active tz.
|
|
26082
|
+
last_str = format_display_dt(
|
|
26083
|
+
s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
|
|
26084
|
+
)
|
|
26085
|
+
models_text = ", ".join(s.models) if s.models else "—"
|
|
26086
|
+
snap_rows.append(_lib_share.Row(cells={
|
|
26087
|
+
"session": _lib_share.TextCell(sid_short),
|
|
26088
|
+
"project": _lib_share.ProjectCell(proj_label),
|
|
26089
|
+
"cost": _lib_share.MoneyCell(cost_usd),
|
|
26090
|
+
"last_activity": _lib_share.TextCell(last_str),
|
|
26091
|
+
"models": _lib_share.TextCell(models_text),
|
|
26092
|
+
}))
|
|
26093
|
+
chart_pts.append(_lib_share.ChartPoint(
|
|
26094
|
+
x_label=proj_label,
|
|
26095
|
+
x_value=cost_usd,
|
|
26096
|
+
y_value=cost_usd,
|
|
26097
|
+
project_label=proj_label,
|
|
26098
|
+
))
|
|
26099
|
+
chart = (
|
|
26100
|
+
_lib_share.HorizontalBarChart(
|
|
26101
|
+
points=tuple(chart_pts), x_label="$", cap=15,
|
|
26102
|
+
)
|
|
26103
|
+
if chart_pts else None
|
|
26104
|
+
)
|
|
26105
|
+
notes: tuple[str, ...] = ()
|
|
26106
|
+
if chart is not None and len(chart_pts) > 15:
|
|
26107
|
+
notes = (
|
|
26108
|
+
f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
|
|
26109
|
+
)
|
|
26110
|
+
sum_cost = sum(p.y_value for p in chart_pts)
|
|
26111
|
+
totals = (
|
|
26112
|
+
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
26113
|
+
_lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
|
|
26114
|
+
)
|
|
26115
|
+
if sorted_sessions:
|
|
26116
|
+
if truncated:
|
|
26117
|
+
title = f"Top {len(snap_rows)} sessions"
|
|
26118
|
+
else:
|
|
26119
|
+
title = (
|
|
26120
|
+
f"Sessions — {period_start.strftime('%b %d')} → "
|
|
26121
|
+
f"{period_end.strftime('%b %d')}"
|
|
26122
|
+
)
|
|
26123
|
+
else:
|
|
26124
|
+
title = "Sessions — no data"
|
|
26125
|
+
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
26126
|
+
subtitle = " · ".join([
|
|
26127
|
+
period_label,
|
|
26128
|
+
theme,
|
|
26129
|
+
"real projects" if reveal_projects else "projects anonymized",
|
|
26130
|
+
])
|
|
26131
|
+
return _lib_share.ShareSnapshot(
|
|
26132
|
+
cmd="session",
|
|
26133
|
+
title=title,
|
|
26134
|
+
subtitle=subtitle,
|
|
26135
|
+
period=_lib_share.PeriodSpec(
|
|
26136
|
+
start=period_start, end=period_end,
|
|
26137
|
+
display_tz=display_tz, label=period_label,
|
|
26138
|
+
),
|
|
26139
|
+
columns=columns, rows=tuple(snap_rows),
|
|
26140
|
+
chart=chart, totals=totals, notes=notes,
|
|
26141
|
+
generated_at=_share_now_utc(), version=version,
|
|
26142
|
+
)
|
|
26143
|
+
|
|
26144
|
+
|
|
26145
|
+
def _share_render_and_emit(snap, args) -> None:
|
|
26146
|
+
"""End-to-end: scrub -> render -> emit -> optional open.
|
|
26147
|
+
|
|
26148
|
+
Lazy-imports `_lib_share` so non-share invocations don't pay the import
|
|
26149
|
+
cost. The kernel module stays I/O-pure; this wrapper does all the
|
|
26150
|
+
side-effecting glue (destination resolution, file writes, clipboard,
|
|
26151
|
+
post-write `--open` launch).
|
|
26152
|
+
|
|
26153
|
+
Caller contract: ``args.format`` MUST be set ("md", "html", or "svg").
|
|
26154
|
+
The wrapper raises ValueError if called without it — surfaces the
|
|
26155
|
+
contract failure at the chokepoint instead of producing junk filenames
|
|
26156
|
+
like ``cctally-daily-<date>.None``.
|
|
26157
|
+
"""
|
|
26158
|
+
if args.format is None:
|
|
26159
|
+
raise ValueError("_share_render_and_emit called without args.format")
|
|
26160
|
+
if args.open_after_write and args.format == "md":
|
|
26161
|
+
# Spec Section 4.4: --open is only meaningful for html/svg writes.
|
|
26162
|
+
# Reject explicitly with exit 2 instead of silently no-opping (which
|
|
26163
|
+
# the prior implementation did because the open-after-write branch
|
|
26164
|
+
# gates on ``kind == "file"``, and md routes to stdout by default).
|
|
26165
|
+
print(
|
|
26166
|
+
"cctally: --open is only valid with --format html or --format svg",
|
|
26167
|
+
file=sys.stderr,
|
|
26168
|
+
)
|
|
26169
|
+
sys.exit(2)
|
|
26170
|
+
# Routed through `_share_load_lib` so wrapper / builders / test harness
|
|
26171
|
+
# share one cached module object — see helper docstring for the
|
|
26172
|
+
# class-identity invariant this enforces.
|
|
26173
|
+
_lib_share = _share_load_lib()
|
|
26174
|
+
|
|
26175
|
+
scrubbed = _lib_share._scrub(snap, reveal_projects=args.reveal_projects)
|
|
26176
|
+
rendered = _lib_share.render(
|
|
26177
|
+
scrubbed,
|
|
26178
|
+
format=args.format,
|
|
26179
|
+
theme=args.theme,
|
|
26180
|
+
branding=not args.no_branding,
|
|
26181
|
+
)
|
|
26182
|
+
|
|
26183
|
+
utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
|
|
26184
|
+
kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
|
|
26185
|
+
_emit(rendered, kind=kind, value=value)
|
|
26186
|
+
|
|
26187
|
+
if args.open_after_write and kind == "file":
|
|
26188
|
+
_share_open_file(pathlib.Path(value))
|
|
26189
|
+
|
|
26190
|
+
|
|
26191
|
+
def _share_open_file(path: pathlib.Path) -> None:
|
|
26192
|
+
"""Run `open` (macOS) / `xdg-open` (Linux). Silent fail if launcher missing."""
|
|
26193
|
+
for launcher in ("open", "xdg-open"):
|
|
26194
|
+
if shutil.which(launcher):
|
|
26195
|
+
subprocess.Popen(
|
|
26196
|
+
[launcher, str(path)],
|
|
26197
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
26198
|
+
)
|
|
26199
|
+
return
|
|
26200
|
+
sys.stderr.write("cctally: --open requires `open` or `xdg-open` on PATH; skipped\n")
|
|
26201
|
+
|
|
26202
|
+
|
|
23831
26203
|
# ============================================================
|
|
23832
26204
|
# ==== TUI ==== =
|
|
23833
26205
|
# ============================================================
|
|
@@ -26276,13 +28648,22 @@ def _select_current_block_for_envelope(
|
|
|
26276
28648
|
envelope convention (``current_week`` is snake_case; CLI ``--json`` is
|
|
26277
28649
|
camelCase — separate conventions, see CLAUDE.md).
|
|
26278
28650
|
|
|
26279
|
-
Delta
|
|
26280
|
-
|
|
26281
|
-
|
|
28651
|
+
Delta semantics:
|
|
28652
|
+
- Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
|
|
28653
|
+
(the natural "how much 7d% has changed during this 5h block" read).
|
|
28654
|
+
- Crossed block (``crossed_seven_day_reset == 1``): the block straddles
|
|
28655
|
+
a weekly reset, so the natural delta would be dominated by the
|
|
28656
|
+
reset itself (e.g. −94pp) rather than the user's actual burn-rate.
|
|
28657
|
+
Compute the POST-RESET delta instead — ``current_used_pct -
|
|
28658
|
+
weekly_percent_at_first_post_reset_snapshot_in_block``. The React
|
|
28659
|
+
panel prefixes this delta with ``⚡`` to show the reset crossing
|
|
28660
|
+
without hiding the informative number.
|
|
28661
|
+
- ``None`` only when ``current_used_pct`` is unknown OR the
|
|
28662
|
+
block-start anchor is missing AND no post-reset anchor was found.
|
|
26282
28663
|
"""
|
|
26283
28664
|
snap = conn.execute(
|
|
26284
28665
|
"""
|
|
26285
|
-
SELECT five_hour_window_key
|
|
28666
|
+
SELECT five_hour_window_key, week_start_at
|
|
26286
28667
|
FROM weekly_usage_snapshots
|
|
26287
28668
|
WHERE captured_at_utc <= ?
|
|
26288
28669
|
ORDER BY captured_at_utc DESC, id DESC
|
|
@@ -26295,7 +28676,8 @@ def _select_current_block_for_envelope(
|
|
|
26295
28676
|
|
|
26296
28677
|
block = conn.execute(
|
|
26297
28678
|
"""
|
|
26298
|
-
SELECT block_start_at,
|
|
28679
|
+
SELECT block_start_at, last_observed_at_utc,
|
|
28680
|
+
seven_day_pct_at_block_start,
|
|
26299
28681
|
crossed_seven_day_reset
|
|
26300
28682
|
FROM five_hour_blocks
|
|
26301
28683
|
WHERE five_hour_window_key = ?
|
|
@@ -26309,9 +28691,39 @@ def _select_current_block_for_envelope(
|
|
|
26309
28691
|
|
|
26310
28692
|
crossed = bool(block["crossed_seven_day_reset"])
|
|
26311
28693
|
p_start = block["seven_day_pct_at_block_start"]
|
|
28694
|
+
|
|
28695
|
+
# When the block crossed a weekly reset, recompute the delta against
|
|
28696
|
+
# the first post-reset snapshot inside the block instead of the
|
|
28697
|
+
# pre-reset block-start anchor. Use ``unixepoch()`` because
|
|
28698
|
+
# ``block_start_at`` is host-local-tz (``+03:00``) while
|
|
28699
|
+
# ``captured_at_utc`` is canonical UTC-Z; lex compares mis-order
|
|
28700
|
+
# mixed-offset moments. ``snap.week_start_at`` is the latest
|
|
28701
|
+
# (post-reset) week's anchor, so equality on that column scopes
|
|
28702
|
+
# the lookup to the current weekly window.
|
|
28703
|
+
p_anchor = p_start
|
|
28704
|
+
if crossed and snap["week_start_at"] is not None:
|
|
28705
|
+
post = conn.execute(
|
|
28706
|
+
"""
|
|
28707
|
+
SELECT weekly_percent
|
|
28708
|
+
FROM weekly_usage_snapshots
|
|
28709
|
+
WHERE week_start_at = ?
|
|
28710
|
+
AND unixepoch(captured_at_utc) >= unixepoch(?)
|
|
28711
|
+
AND unixepoch(captured_at_utc) <= unixepoch(?)
|
|
28712
|
+
ORDER BY captured_at_utc ASC, id ASC
|
|
28713
|
+
LIMIT 1
|
|
28714
|
+
""",
|
|
28715
|
+
(
|
|
28716
|
+
snap["week_start_at"],
|
|
28717
|
+
block["block_start_at"],
|
|
28718
|
+
_iso_z(now_utc),
|
|
28719
|
+
),
|
|
28720
|
+
).fetchone()
|
|
28721
|
+
if post is not None and post["weekly_percent"] is not None:
|
|
28722
|
+
p_anchor = float(post["weekly_percent"])
|
|
28723
|
+
|
|
26312
28724
|
delta = (
|
|
26313
|
-
None if (
|
|
26314
|
-
else round(current_used_pct -
|
|
28725
|
+
None if (p_anchor is None or current_used_pct is None)
|
|
28726
|
+
else round(current_used_pct - p_anchor, 9)
|
|
26315
28727
|
)
|
|
26316
28728
|
return {
|
|
26317
28729
|
"block_start_at": block["block_start_at"],
|