cctally 1.3.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/bin/cctally CHANGED
@@ -83,7 +83,17 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
83
83
 
84
84
  # === Release automation (issue #24) ===
85
85
 
86
- CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
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:
@@ -1623,8 +1633,40 @@ def _release_run_phase_brew(
1623
1633
  )
1624
1634
  # Tag is best-effort — re-running after a partial publish should
1625
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"
1626
1667
  subprocess.run(
1627
- ["git", "-C", str(brew_clone), "tag", f"v{version}"],
1668
+ ["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
1669
+ f"cctally v{version}", f"v{version}"],
1628
1670
  check=False,
1629
1671
  )
1630
1672
  # Single ATOMIC push of branch + tag in one transaction — the
@@ -1633,17 +1675,24 @@ def _release_run_phase_brew(
1633
1675
  # split branch-push + tag-push pair admits: a tag landing without
1634
1676
  # the branch landing would leave `brew install` serving the OLD
1635
1677
  # formula off the tap's default branch even though the remote
1636
- # carries the new tag. Tag refspec is explicit (`src:dst`) because
1637
- # `--follow-tags` skips lightweight tags and Phase 6's tag is
1638
- # lightweight (no `-a`/`-m`).
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.
1639
1681
  push = subprocess.run(
1640
1682
  ["git", "-C", str(brew_clone), "push", "--atomic", "origin",
1641
1683
  "HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
1642
1684
  check=False,
1643
1685
  )
1644
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.
1645
1691
  print(
1646
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"
1647
1696
  f" git -C {brew_clone} push --atomic origin HEAD "
1648
1697
  f"refs/tags/v{version}:refs/tags/v{version}\n",
1649
1698
  file=sys.stderr,
@@ -8190,6 +8239,41 @@ def _render_claude_session_table(
8190
8239
  return "\n".join(out)
8191
8240
 
8192
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
+
8193
8277
  def _render_project_table(
8194
8278
  rows: list[dict],
8195
8279
  *,
@@ -8248,22 +8332,11 @@ def _render_project_table(
8248
8332
  ampm = "a.m." if local.hour < 12 else "p.m."
8249
8333
  return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
8250
8334
 
8251
- # Basename-collision disambiguation: if two rows share a display_key,
8252
- # suffix with the parent directory segment so the user can tell them
8253
- # apart. Prefer the git_root as the disambiguation source when present;
8254
- # fall back to bucket_path for no-git rows (whose displays also collide
8255
- # on basename even though they each carry a `(no-git)` marker).
8256
- display_counts: dict[str, int] = {}
8257
- for r in rows:
8258
- dk = r["key"].display_key
8259
- display_counts[dk] = display_counts.get(dk, 0) + 1
8260
- augmented: dict[int, str] = {}
8261
- for idx, r in enumerate(rows):
8262
- if display_counts[r["key"].display_key] > 1:
8263
- source_path = r["key"].git_root or r["key"].bucket_path
8264
- if source_path:
8265
- parent = os.path.basename(os.path.dirname(source_path)) or "/"
8266
- 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)
8267
8340
 
8268
8341
  def _project_cell(idx: int, r: dict) -> tuple[str, Any]:
8269
8342
  """Return (plain_text, color_fn_or_None) for the Project cell.
@@ -12283,12 +12356,36 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
12283
12356
  )
12284
12357
  pending_alerts.append(payload)
12285
12358
 
12286
- # ── Mid-week reset cross-flag (opportunistic, JOIN-based) ──
12287
- # Self-healing sweep against week_reset_events: every tick,
12288
- # flag any open block whose [block_start_at,
12289
- # last_observed_at_utc] interval contains a recorded reset
12290
- # moment. Symmetric with the historical-backfill predicate
12291
- # (§4.2 step 5). Idempotent (only flips 0 1).
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.
12292
12389
  #
12293
12390
  # Why the JOIN rather than a per-tick param: an earlier
12294
12391
  # design passed mid_week_reset_at only on the tick that
@@ -12303,13 +12400,21 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
12303
12400
  UPDATE five_hour_blocks
12304
12401
  SET crossed_seven_day_reset = 1
12305
12402
  WHERE crossed_seven_day_reset = 0
12306
- AND id IN (
12307
- SELECT b.id
12308
- FROM five_hour_blocks b
12309
- JOIN week_reset_events e
12310
- ON e.effective_reset_at_utc
12311
- BETWEEN b.block_start_at
12312
- AND b.last_observed_at_utc
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
+ )
12313
12418
  )
12314
12419
  """,
12315
12420
  )
@@ -12419,16 +12524,36 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
12419
12524
  last_obs_dt = parse_iso_datetime(last_obs, "last_obs backfill")
12420
12525
 
12421
12526
  # Cross-reset detection (interval predicate, symmetric with
12422
- # 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.
12423
12537
  cross_row = conn.execute(
12424
12538
  """
12425
12539
  SELECT 1 FROM week_reset_events
12426
- WHERE effective_reset_at_utc >= ?
12427
- AND effective_reset_at_utc <= ?
12540
+ WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
12541
+ AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
12428
12542
  LIMIT 1
12429
12543
  """,
12430
12544
  (block_start_at, last_obs),
12431
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()
12432
12557
  crossed = 1 if cross_row is not None else 0
12433
12558
 
12434
12559
  # is_closed: 1 if the canonical reset moment is already past.
@@ -14208,6 +14333,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
14208
14333
 
14209
14334
  def cmd_daily(args: argparse.Namespace) -> int:
14210
14335
  """Show usage report grouped by display-timezone date."""
14336
+ _share_validate_args(args)
14211
14337
  config = load_config()
14212
14338
  tz = resolve_display_tz(args, config)
14213
14339
  args._resolved_tz = tz
@@ -14225,6 +14351,35 @@ def cmd_daily(args: argparse.Namespace) -> int:
14225
14351
  # Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
14226
14352
  days = _aggregate_daily(all_entries, mode="auto", tz=tz)
14227
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
+
14228
14383
  # Apply sort order.
14229
14384
  if args.order == "desc":
14230
14385
  days = list(reversed(days))
@@ -14246,6 +14401,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
14246
14401
 
14247
14402
  def cmd_monthly(args: argparse.Namespace) -> int:
14248
14403
  """Show usage report grouped by display-timezone calendar month."""
14404
+ _share_validate_args(args)
14249
14405
  config = load_config()
14250
14406
  tz = resolve_display_tz(args, config)
14251
14407
  args._resolved_tz = tz
@@ -14261,6 +14417,32 @@ def cmd_monthly(args: argparse.Namespace) -> int:
14261
14417
 
14262
14418
  months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
14263
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
+
14264
14446
  if args.order == "desc":
14265
14447
  months = list(reversed(months))
14266
14448
 
@@ -14280,6 +14462,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
14280
14462
 
14281
14463
  def cmd_weekly(args: argparse.Namespace) -> int:
14282
14464
  """Show Claude usage grouped by subscription week."""
14465
+ _share_validate_args(args)
14283
14466
  config = load_config()
14284
14467
  args._resolved_tz = resolve_display_tz(args, config)
14285
14468
 
@@ -14356,6 +14539,33 @@ def cmd_weekly(args: argparse.Namespace) -> int:
14356
14539
  else:
14357
14540
  overlay.append((None, None))
14358
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
+
14359
14569
  # Apply sort order. Buckets and overlay must reverse together so their
14360
14570
  # indices stay aligned (both _render_weekly_table and _weekly_to_json
14361
14571
  # assert len equality).
@@ -14824,6 +15034,7 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
14824
15034
 
14825
15035
  def cmd_project(args: argparse.Namespace) -> int:
14826
15036
  """Roll entries up by project (git-root) with per-project usage attribution."""
15037
+ _share_validate_args(args)
14827
15038
  config = load_config()
14828
15039
  args._resolved_tz = resolve_display_tz(args, config)
14829
15040
 
@@ -15162,6 +15373,33 @@ def cmd_project(args: argparse.Namespace) -> int:
15162
15373
  key=lambda r: _project_sort_key(r, args.sort, args.order),
15163
15374
  )
15164
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
+
15165
15403
  if args.json:
15166
15404
  print(_project_json_output(
15167
15405
  since=since_dt,
@@ -16767,6 +17005,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
16767
17005
 
16768
17006
  def cmd_session(args: argparse.Namespace) -> int:
16769
17007
  """Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
17008
+ _share_validate_args(args)
16770
17009
  config = load_config()
16771
17010
  tz = resolve_display_tz(args, config)
16772
17011
  args._resolved_tz = tz
@@ -16780,6 +17019,52 @@ def cmd_session(args: argparse.Namespace) -> int:
16780
17019
 
16781
17020
  entries = get_claude_session_entries(range_start, range_end)
16782
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
+
16783
17068
  # Aggregator returns descending by last_activity; --order asc reverses.
16784
17069
  if args.order == "asc":
16785
17070
  sessions = list(reversed(sessions))
@@ -18024,6 +18309,7 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
18024
18309
 
18025
18310
 
18026
18311
  def cmd_report(args: argparse.Namespace) -> int:
18312
+ _share_validate_args(args)
18027
18313
  if args.sync_current:
18028
18314
  sync_ns = argparse.Namespace(
18029
18315
  week_start=None,
@@ -18071,6 +18357,40 @@ def cmd_report(args: argparse.Namespace) -> int:
18071
18357
 
18072
18358
  weeks = get_recent_weeks(conn, max(1, args.weeks))
18073
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
18074
18394
  if args.json:
18075
18395
  print(json.dumps({"current": None, "trend": []}, indent=2))
18076
18396
  else:
@@ -18183,6 +18503,39 @@ def cmd_report(args: argparse.Namespace) -> int:
18183
18503
  for m in milestone_rows
18184
18504
  ]
18185
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
+
18186
18539
  if args.json:
18187
18540
  print(json.dumps(output, indent=2))
18188
18541
  return 0
@@ -18294,6 +18647,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18294
18647
  """Project current-week usage to reset boundary. Emit terminal report,
18295
18648
  JSON, or status-line one-liner. See docs/commands/forecast.md.
18296
18649
  """
18650
+ _share_validate_args(args)
18297
18651
  if args.json and args.status_line:
18298
18652
  print("forecast: --json and --status-line are mutually exclusive",
18299
18653
  file=sys.stderr)
@@ -18319,6 +18673,58 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18319
18673
  inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
18320
18674
  if inputs is None:
18321
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
18322
18728
  if args.json:
18323
18729
  print(json.dumps({
18324
18730
  "error": "no_current_week_data",
@@ -18332,6 +18738,94 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18332
18738
 
18333
18739
  output = _compute_forecast(inputs, targets)
18334
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
+
18335
18829
  if args.json:
18336
18830
  print(_emit_forecast_json(output))
18337
18831
  return 0
@@ -18760,6 +19254,7 @@ def _render_five_hour_blocks_table(
18760
19254
 
18761
19255
  def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
18762
19256
  """List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
19257
+ _share_validate_args(args)
18763
19258
  config = load_config()
18764
19259
  args._resolved_tz = resolve_display_tz(args, config)
18765
19260
  # Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
@@ -18836,6 +19331,61 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
18836
19331
  d["seven_day_pct_at_block_end"] = latest_7d
18837
19332
  block_dicts.append(d)
18838
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
+
18839
19389
  # Optional breakdown.
18840
19390
  if args.breakdown:
18841
19391
  for bd in block_dicts:
@@ -19246,6 +19796,90 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
19246
19796
  conn.close()
19247
19797
 
19248
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}")
19249
19883
  return 0
19250
19884
 
19251
19885
  payload = {
@@ -20435,6 +21069,44 @@ def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict
20435
21069
  return out
20436
21070
 
20437
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
+
20438
21110
 
20439
21111
  def _resolve_cache_report_window(
20440
21112
  args: argparse.Namespace,
@@ -22270,6 +22942,142 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
22270
22942
  return 0
22271
22943
 
22272
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
+
22273
23081
  def build_parser() -> argparse.ArgumentParser:
22274
23082
  p = argparse.ArgumentParser(
22275
23083
  prog="cctally",
@@ -22434,9 +23242,11 @@ def build_parser() -> argparse.ArgumentParser:
22434
23242
  help="Project filter passed to sync-week when --sync-current is used.",
22435
23243
  )
22436
23244
  pr.add_argument(
22437
- "--json",
23245
+ "--reveal-projects",
22438
23246
  action="store_true",
22439
- help="Emit machine-readable JSON output.",
23247
+ dest="reveal_projects",
23248
+ help="In --format output, show real project basenames instead of "
23249
+ "the default project-1, project-2, ... anonymization.",
22440
23250
  )
22441
23251
  pr.add_argument(
22442
23252
  "--detail",
@@ -22448,6 +23258,7 @@ def build_parser() -> argparse.ArgumentParser:
22448
23258
  help="Display timezone: local, utc, or IANA name. "
22449
23259
  "Overrides config display.tz for this call.",
22450
23260
  )
23261
+ _add_share_args(pr)
22451
23262
  pr.set_defaults(func=cmd_report)
22452
23263
 
22453
23264
  fc = sub.add_parser(
@@ -22475,10 +23286,9 @@ def build_parser() -> argparse.ArgumentParser:
22475
23286
  """
22476
23287
  ),
22477
23288
  )
22478
- fc.add_argument("--json", action="store_true",
22479
- help="Emit machine-readable JSON; suppresses terminal render.")
22480
- fc.add_argument("--status-line", action="store_true", dest="status_line",
22481
- 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.")
22482
23292
  fc.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
22483
23293
  help="Display timezone: local, utc, or IANA name. "
22484
23294
  "Overrides config display.tz for this call.")
@@ -22492,6 +23302,7 @@ def build_parser() -> argparse.ArgumentParser:
22492
23302
  help="Color output control (also honors NO_COLOR).")
22493
23303
  # Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
22494
23304
  fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
23305
+ _add_share_args(fc, has_status_line=True)
22495
23306
  fc.set_defaults(func=cmd_forecast)
22496
23307
 
22497
23308
  pb = sub.add_parser(
@@ -23063,9 +23874,11 @@ def build_parser() -> argparse.ArgumentParser:
23063
23874
  help="Add per-axis rollup-child rows under each block.",
23064
23875
  )
23065
23876
  fhb.add_argument(
23066
- "--json",
23877
+ "--reveal-projects",
23067
23878
  action="store_true",
23068
- help="Emit camelCase JSON (schemaVersion 1).",
23879
+ dest="reveal_projects",
23880
+ help="In --format output, show real project basenames instead of "
23881
+ "the default project-1, project-2, ... anonymization.",
23069
23882
  )
23070
23883
  fhb.add_argument(
23071
23884
  "--no-color",
@@ -23080,6 +23893,7 @@ def build_parser() -> argparse.ArgumentParser:
23080
23893
  help="Display timezone: local, utc, or IANA name. "
23081
23894
  "Overrides config display.tz for this call.",
23082
23895
  )
23896
+ _add_share_args(fhb)
23083
23897
  fhb.set_defaults(func=cmd_five_hour_blocks)
23084
23898
 
23085
23899
  # -- cache-sync --
@@ -23139,16 +23953,18 @@ def build_parser() -> argparse.ArgumentParser:
23139
23953
  help="Sort direction by date (default: asc).",
23140
23954
  )
23141
23955
  dy.add_argument(
23142
- "--json",
23956
+ "--reveal-projects",
23143
23957
  action="store_true",
23144
- dest="json",
23145
- help="Output JSON matching upstream ccusage daily format.",
23958
+ dest="reveal_projects",
23959
+ help="In --format output, show real project basenames instead of "
23960
+ "the default project-1, project-2, ... anonymization.",
23146
23961
  )
23147
23962
  dy.add_argument(
23148
23963
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
23149
23964
  help="Display timezone: local, utc, or IANA name. "
23150
23965
  "Overrides config display.tz for this call.",
23151
23966
  )
23967
+ _add_share_args(dy)
23152
23968
  dy.set_defaults(func=cmd_daily)
23153
23969
 
23154
23970
  # -- monthly --
@@ -23190,16 +24006,18 @@ def build_parser() -> argparse.ArgumentParser:
23190
24006
  help="Sort direction by month (default: asc).",
23191
24007
  )
23192
24008
  mo.add_argument(
23193
- "--json",
24009
+ "--reveal-projects",
23194
24010
  action="store_true",
23195
- dest="json",
23196
- help="Output JSON matching upstream ccusage monthly format.",
24011
+ dest="reveal_projects",
24012
+ help="In --format output, show real project basenames instead of "
24013
+ "the default project-1, project-2, ... anonymization.",
23197
24014
  )
23198
24015
  mo.add_argument(
23199
24016
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
23200
24017
  help="Display timezone: local, utc, or IANA name. "
23201
24018
  "Overrides config display.tz for this call.",
23202
24019
  )
24020
+ _add_share_args(mo)
23203
24021
  mo.set_defaults(func=cmd_monthly)
23204
24022
 
23205
24023
  # -- weekly --
@@ -23228,11 +24046,13 @@ def build_parser() -> argparse.ArgumentParser:
23228
24046
  help="Show per-model cost breakdown sub-rows.")
23229
24047
  we.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
23230
24048
  help="Sort direction by week (default: asc).")
23231
- we.add_argument("--json", action="store_true", dest="json",
23232
- help="Output JSON.")
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.")
23233
24052
  we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23234
24053
  help="Display timezone: local, utc, or IANA name. "
23235
24054
  "Overrides config display.tz for this call.")
24055
+ _add_share_args(we)
23236
24056
  we.set_defaults(func=cmd_weekly)
23237
24057
 
23238
24058
  # -- codex shared args helper --
@@ -23428,13 +24248,15 @@ def build_parser() -> argparse.ArgumentParser:
23428
24248
  help="Sort key (default: cost).")
23429
24249
  p_project.add_argument("--group", choices=("git-root", "full-path"), default="git-root",
23430
24250
  help="Bucket by resolved git-root (default) or raw project_path.")
23431
- p_project.add_argument("--json", action="store_true", dest="json",
23432
- help="Emit JSON instead of table.")
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.")
23433
24254
  p_project.add_argument("--no-color", action="store_true", dest="no_color",
23434
24255
  help="Disable ANSI color.")
23435
24256
  p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23436
24257
  help="Display timezone: local, utc, or IANA name. "
23437
24258
  "Overrides config display.tz for this call.")
24259
+ _add_share_args(p_project)
23438
24260
  p_project.set_defaults(func=cmd_project)
23439
24261
 
23440
24262
  # -- diff --
@@ -23495,11 +24317,18 @@ def build_parser() -> argparse.ArgumentParser:
23495
24317
  help="Show per-model cost breakdown sub-rows.")
23496
24318
  se.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
23497
24319
  help="Sort direction by last activity (default: asc — earliest first).")
23498
- se.add_argument("--json", action="store_true", dest="json",
23499
- help="Output JSON.")
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.")
23500
24328
  se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23501
24329
  help="Display timezone: local, utc, or IANA name. "
23502
24330
  "Overrides config display.tz for this call.")
24331
+ _add_share_args(se)
23503
24332
  se.set_defaults(func=cmd_session)
23504
24333
 
23505
24334
  # ---- config (persisted user preferences) ----
@@ -23833,6 +24662,1544 @@ def build_parser() -> argparse.ArgumentParser:
23833
24662
  return p
23834
24663
 
23835
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
+
23836
26203
  # ============================================================
23837
26204
  # ==== TUI ==== =
23838
26205
  # ============================================================
@@ -26281,13 +28648,22 @@ def _select_current_block_for_envelope(
26281
28648
  envelope convention (``current_week`` is snake_case; CLI ``--json`` is
26282
28649
  camelCase — separate conventions, see CLAUDE.md).
26283
28650
 
26284
- Delta is suppressed (``None``) when ``crossed_seven_day_reset == 1`` OR
26285
- when ``seven_day_pct_at_block_start IS NULL`` OR when
26286
- ``current_used_pct`` is None.
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.
26287
28663
  """
26288
28664
  snap = conn.execute(
26289
28665
  """
26290
- SELECT five_hour_window_key
28666
+ SELECT five_hour_window_key, week_start_at
26291
28667
  FROM weekly_usage_snapshots
26292
28668
  WHERE captured_at_utc <= ?
26293
28669
  ORDER BY captured_at_utc DESC, id DESC
@@ -26300,7 +28676,8 @@ def _select_current_block_for_envelope(
26300
28676
 
26301
28677
  block = conn.execute(
26302
28678
  """
26303
- SELECT block_start_at, seven_day_pct_at_block_start,
28679
+ SELECT block_start_at, last_observed_at_utc,
28680
+ seven_day_pct_at_block_start,
26304
28681
  crossed_seven_day_reset
26305
28682
  FROM five_hour_blocks
26306
28683
  WHERE five_hour_window_key = ?
@@ -26314,9 +28691,39 @@ def _select_current_block_for_envelope(
26314
28691
 
26315
28692
  crossed = bool(block["crossed_seven_day_reset"])
26316
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
+
26317
28724
  delta = (
26318
- None if (crossed or p_start is None or current_used_pct is None)
26319
- else round(current_used_pct - p_start, 9)
28725
+ None if (p_anchor is None or current_used_pct is None)
28726
+ else round(current_used_pct - p_anchor, 9)
26320
28727
  )
26321
28728
  return {
26322
28729
  "block_start_at": block["block_start_at"],