cctally 1.7.4 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/bin/_cctally_dashboard.py +135 -123
- package/bin/_cctally_tui.py +124 -256
- package/bin/_lib_view_models.py +993 -0
- package/bin/cctally +323 -238
- package/dashboard/static/assets/{index-DhCnIFq9.js → index-CfXu9Fx_.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +9 -8
package/bin/cctally
CHANGED
|
@@ -80,6 +80,7 @@ import textwrap
|
|
|
80
80
|
import threading
|
|
81
81
|
import time
|
|
82
82
|
import traceback
|
|
83
|
+
import unicodedata
|
|
83
84
|
import urllib.error
|
|
84
85
|
import urllib.request
|
|
85
86
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
@@ -345,6 +346,20 @@ _aggregate_codex_sessions = _lib_aggregators._aggregate_codex_sessions
|
|
|
345
346
|
_session_path_parts = _lib_aggregators._session_path_parts
|
|
346
347
|
_aggregate_claude_sessions = _lib_aggregators._aggregate_claude_sessions
|
|
347
348
|
|
|
349
|
+
# View-model kernel — per-domain frozen dataclasses + builders.
|
|
350
|
+
# Spec: docs/superpowers/specs/2026-05-17-view-model-unification-design.md.
|
|
351
|
+
_lib_view_models = _load_sibling("_lib_view_models")
|
|
352
|
+
DailyView = _lib_view_models.DailyView
|
|
353
|
+
build_daily_view = _lib_view_models.build_daily_view
|
|
354
|
+
MonthlyView = _lib_view_models.MonthlyView
|
|
355
|
+
build_monthly_view = _lib_view_models.build_monthly_view
|
|
356
|
+
WeeklyView = _lib_view_models.WeeklyView
|
|
357
|
+
build_weekly_view = _lib_view_models.build_weekly_view
|
|
358
|
+
TrendView = _lib_view_models.TrendView
|
|
359
|
+
build_trend_view = _lib_view_models.build_trend_view
|
|
360
|
+
SessionsView = _lib_view_models.SessionsView
|
|
361
|
+
build_sessions_view = _lib_view_models.build_sessions_view
|
|
362
|
+
|
|
348
363
|
_lib_render = _load_sibling("_lib_render")
|
|
349
364
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
350
365
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
@@ -2677,6 +2692,29 @@ def _supports_unicode_stdout() -> bool:
|
|
|
2677
2692
|
return "UTF" in encoding
|
|
2678
2693
|
|
|
2679
2694
|
|
|
2695
|
+
def _display_width(s: str) -> int:
|
|
2696
|
+
"""Terminal cells consumed by ``s``.
|
|
2697
|
+
|
|
2698
|
+
Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
|
|
2699
|
+
/ Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
|
|
2700
|
+
Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
|
|
2701
|
+
locale — cctally has no CJK content in cell data, and `→` / `—` /
|
|
2702
|
+
`·` (all `A`) are intentionally rendered narrow.
|
|
2703
|
+
|
|
2704
|
+
Used by `_boxed_table` so cells containing wide glyphs (notably
|
|
2705
|
+
`⚡` U+26A1 on credit-row annotations) pad to the right cell count
|
|
2706
|
+
rather than the right codepoint count. Without this, `len()`-based
|
|
2707
|
+
padding under-pads by one cell per wide glyph and the right border
|
|
2708
|
+
drifts off-column on those rows only.
|
|
2709
|
+
"""
|
|
2710
|
+
width = 0
|
|
2711
|
+
for ch in s:
|
|
2712
|
+
if unicodedata.combining(ch):
|
|
2713
|
+
continue
|
|
2714
|
+
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
2715
|
+
return width
|
|
2716
|
+
|
|
2717
|
+
|
|
2680
2718
|
def _boxed_table(
|
|
2681
2719
|
headers: list[str],
|
|
2682
2720
|
rows: list[list[str]],
|
|
@@ -2700,15 +2738,20 @@ def _boxed_table(
|
|
|
2700
2738
|
|
|
2701
2739
|
widths: list[int] = []
|
|
2702
2740
|
for idx, header in enumerate(headers):
|
|
2703
|
-
max_cell = max((
|
|
2704
|
-
widths.append(max(
|
|
2741
|
+
max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
|
|
2742
|
+
widths.append(max(_display_width(header), max_cell))
|
|
2705
2743
|
|
|
2706
2744
|
def _pad(text: str, width: int, align: str) -> str:
|
|
2745
|
+
deficit = width - _display_width(text)
|
|
2746
|
+
if deficit <= 0:
|
|
2747
|
+
return text
|
|
2748
|
+
pad = " " * deficit
|
|
2707
2749
|
if align == "right":
|
|
2708
|
-
return text
|
|
2750
|
+
return pad + text
|
|
2709
2751
|
if align == "center":
|
|
2710
|
-
|
|
2711
|
-
|
|
2752
|
+
left = deficit // 2
|
|
2753
|
+
return (" " * left) + text + (" " * (deficit - left))
|
|
2754
|
+
return text + pad
|
|
2712
2755
|
|
|
2713
2756
|
if _supports_unicode_stdout():
|
|
2714
2757
|
chars = {
|
|
@@ -5222,8 +5265,19 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
5222
5265
|
# Collect entries.
|
|
5223
5266
|
all_entries = get_entries(range_start, range_end)
|
|
5224
5267
|
|
|
5225
|
-
#
|
|
5226
|
-
|
|
5268
|
+
# Build the unified daily view (spec §5.1: gap-free; the dashboard
|
|
5269
|
+
# heatmap's contiguous-window materialization stays at the dashboard
|
|
5270
|
+
# envelope adapter so CLI byte-stability is preserved). Consume
|
|
5271
|
+
# `view.aggregated` (BucketUsage tuple) for the CLI renderers — the
|
|
5272
|
+
# JSON shape's `bucket` / `model_breakdowns` / `models: list[str]`
|
|
5273
|
+
# fields live on BucketUsage, not on DailyPanelRow. The builder's
|
|
5274
|
+
# `_aggregate_daily` call is the same one we used inline.
|
|
5275
|
+
view = build_daily_view(all_entries, now_utc=_command_as_of(),
|
|
5276
|
+
display_tz=tz)
|
|
5277
|
+
# `_aggregate_daily` returned ascending order; build_daily_view stores
|
|
5278
|
+
# `aggregated` newest-first. CLI's default order is ascending, so
|
|
5279
|
+
# re-reverse to match the prior on-the-wire shape.
|
|
5280
|
+
days = list(reversed(view.aggregated))
|
|
5227
5281
|
|
|
5228
5282
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5229
5283
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5241,7 +5295,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
5241
5295
|
# subcommands (cmd_report's --detail, etc.).
|
|
5242
5296
|
display_tz_str = _share_display_tz_label(tz)
|
|
5243
5297
|
snap = _build_daily_snapshot(
|
|
5244
|
-
|
|
5298
|
+
view,
|
|
5245
5299
|
period_start=range_start,
|
|
5246
5300
|
period_end=range_end,
|
|
5247
5301
|
display_tz=display_tz_str,
|
|
@@ -5291,7 +5345,18 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
5291
5345
|
|
|
5292
5346
|
all_entries = get_entries(range_start, range_end)
|
|
5293
5347
|
|
|
5294
|
-
|
|
5348
|
+
# Build the unified monthly view (spec §5.2: drops boundary-spillover
|
|
5349
|
+
# bucket; computes delta_cost_pct internally). Consume
|
|
5350
|
+
# `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
|
|
5351
|
+
# stability — `_bucket_to_json` reads BucketUsage fields not present
|
|
5352
|
+
# on MonthlyPeriodRow.
|
|
5353
|
+
#
|
|
5354
|
+
# Pass a large `n` so the CLI's `--since`/`--until` window controls
|
|
5355
|
+
# how many months render (the dashboard caps at n=12; CLI doesn't).
|
|
5356
|
+
view = build_monthly_view(all_entries, now_utc=_command_as_of(),
|
|
5357
|
+
n=10**6, display_tz=tz)
|
|
5358
|
+
# The view stores `aggregated` newest-first; CLI default is asc.
|
|
5359
|
+
months = list(reversed(view.aggregated))
|
|
5295
5360
|
|
|
5296
5361
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5297
5362
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5306,7 +5371,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
5306
5371
|
# share spec scope). Same convention as cmd_daily / cmd_report.
|
|
5307
5372
|
display_tz_str = _share_display_tz_label(tz)
|
|
5308
5373
|
snap = _build_monthly_snapshot(
|
|
5309
|
-
|
|
5374
|
+
view,
|
|
5310
5375
|
period_start=range_start,
|
|
5311
5376
|
period_end=range_end,
|
|
5312
5377
|
display_tz=display_tz_str,
|
|
@@ -5371,15 +5436,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5371
5436
|
else:
|
|
5372
5437
|
fetch_start = range_start
|
|
5373
5438
|
all_entries = get_entries(fetch_start, range_end)
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
# Align overlay (used_pct, $/1%) to the same order as `buckets`.
|
|
5377
|
-
# `buckets` may be a subset of `weeks` (weeks with no entries are
|
|
5378
|
-
# dropped by _aggregate_weekly), so we look up each bucket's SubWeek
|
|
5379
|
-
# by `start_date.isoformat()` — the invariant enforced by
|
|
5380
|
-
# _aggregate_weekly is that every emitted bucket key maps to exactly
|
|
5381
|
-
# one SubWeek in `weeks`.
|
|
5382
|
-
#
|
|
5439
|
+
|
|
5383
5440
|
# Bound the usage-snapshot lookup to `<= range_end` so historical
|
|
5384
5441
|
# `--until <past date>` queries pick the usage% that was current at
|
|
5385
5442
|
# the end of the requested window rather than the globally latest
|
|
@@ -5395,25 +5452,19 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5395
5452
|
.isoformat()
|
|
5396
5453
|
.replace("+00:00", "Z")
|
|
5397
5454
|
)
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
if row is not None and row["weekly_percent"] is not None:
|
|
5412
|
-
pct = float(row["weekly_percent"])
|
|
5413
|
-
dpc = (bucket.cost_usd / pct) if pct > 0 else None
|
|
5414
|
-
overlay.append((pct, dpc))
|
|
5415
|
-
else:
|
|
5416
|
-
overlay.append((None, None))
|
|
5455
|
+
|
|
5456
|
+
# Build the unified weekly view (spec §5.3): runs _aggregate_weekly,
|
|
5457
|
+
# overlays weekly_usage_snapshots per WeekRef. view.aggregated is
|
|
5458
|
+
# the BucketUsage tuple newest-first; view.overlay is the parallel
|
|
5459
|
+
# (used_pct, dollar_per_pct) tuple. We reverse both for CLI's
|
|
5460
|
+
# default asc rendering so the existing renderer's len-equality
|
|
5461
|
+
# assertions stay aligned.
|
|
5462
|
+
view = build_weekly_view(
|
|
5463
|
+
conn, all_entries, weeks=weeks, now_utc=now_utc,
|
|
5464
|
+
display_tz=args._resolved_tz, as_of_utc=as_of_utc,
|
|
5465
|
+
)
|
|
5466
|
+
buckets = list(reversed(view.aggregated))
|
|
5467
|
+
overlay = list(reversed(view.overlay))
|
|
5417
5468
|
|
|
5418
5469
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5419
5470
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5428,7 +5479,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5428
5479
|
if getattr(args, "format", None):
|
|
5429
5480
|
display_tz_str = _share_display_tz_label(args._resolved_tz)
|
|
5430
5481
|
snap = _build_weekly_snapshot(
|
|
5431
|
-
|
|
5482
|
+
view,
|
|
5432
5483
|
period_start=range_start,
|
|
5433
5484
|
period_end=range_end,
|
|
5434
5485
|
display_tz=display_tz_str,
|
|
@@ -6506,7 +6557,20 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
6506
6557
|
range_start, range_end = range
|
|
6507
6558
|
|
|
6508
6559
|
entries = get_claude_session_entries(range_start, range_end)
|
|
6509
|
-
|
|
6560
|
+
# Unified view-model kernel (spec §6.5). `limit=None` keeps the
|
|
6561
|
+
# full aggregator output — `cctally session` has no `--limit` flag
|
|
6562
|
+
# and emits every session in the requested range. `view.aggregated`
|
|
6563
|
+
# is the `list[ClaudeSessionUsage]` shape the legacy CLI / share
|
|
6564
|
+
# renderers consume (table, --json, share-snapshot); `view.rows`
|
|
6565
|
+
# is the typed `TuiSessionRow` tuple reserved for the TUI /
|
|
6566
|
+
# dashboard wiring in Task 15 / 16. Keeping both shapes parallel
|
|
6567
|
+
# at the builder preserves the resumed-session merge invariant
|
|
6568
|
+
# documented in CLAUDE.md (one sessionId across multiple JSONL
|
|
6569
|
+
# files collapses to ONE entry in BOTH tuples).
|
|
6570
|
+
view = build_sessions_view(
|
|
6571
|
+
entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
|
|
6572
|
+
)
|
|
6573
|
+
sessions = list(view.aggregated)
|
|
6510
6574
|
|
|
6511
6575
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
6512
6576
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -6540,7 +6604,7 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
6540
6604
|
# cmd_daily / cmd_project.
|
|
6541
6605
|
display_tz_str = _share_display_tz_label(tz)
|
|
6542
6606
|
snap = _build_session_snapshot(
|
|
6543
|
-
|
|
6607
|
+
view,
|
|
6544
6608
|
period_start=range_start,
|
|
6545
6609
|
period_end=range_end,
|
|
6546
6610
|
display_tz=display_tz_str,
|
|
@@ -7874,7 +7938,7 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
7874
7938
|
we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
7875
7939
|
)
|
|
7876
7940
|
snap = _build_report_snapshot(
|
|
7877
|
-
|
|
7941
|
+
TrendView(),
|
|
7878
7942
|
period_start=ws_dt,
|
|
7879
7943
|
period_end=we_dt,
|
|
7880
7944
|
display_tz=display_tz_str,
|
|
@@ -7890,124 +7954,113 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
7890
7954
|
print("No data yet. Add record-usage to your status line script (see record-usage --help).")
|
|
7891
7955
|
return 0
|
|
7892
7956
|
|
|
7893
|
-
trend
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
#
|
|
7897
|
-
#
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
#
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
#
|
|
7907
|
-
#
|
|
7908
|
-
#
|
|
7909
|
-
#
|
|
7910
|
-
#
|
|
7911
|
-
#
|
|
7912
|
-
#
|
|
7913
|
-
#
|
|
7914
|
-
#
|
|
7915
|
-
#
|
|
7916
|
-
#
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7957
|
+
# Build the unified trend view (spec §5.4). `build_trend_view`
|
|
7958
|
+
# owns the per-row construction, including:
|
|
7959
|
+
# - get_latest_usage_for_week with split-key as_of_utc pinning
|
|
7960
|
+
# for credited weeks (Bug D / round-3 Bug B parity)
|
|
7961
|
+
# - _week_ref_has_reset_event → _compute_cost_for_weekref bypass
|
|
7962
|
+
# for reset-affected weeks
|
|
7963
|
+
# - freshness sub-dict derivation
|
|
7964
|
+
# - 3-sample-rule average
|
|
7965
|
+
# Note: build_trend_view returns rows oldest-first (chronological);
|
|
7966
|
+
# cmd_report's JSON contract is newest-first to mirror
|
|
7967
|
+
# get_recent_weeks's order — we reverse below.
|
|
7968
|
+
view = build_trend_view(conn, now_utc=_command_as_of(), n=args.weeks,
|
|
7969
|
+
display_tz=tz)
|
|
7970
|
+
# Serialize TuiTrendRow → today's camelCase keys. Order:
|
|
7971
|
+
# newest-first (matches the prior cmd_report behavior).
|
|
7972
|
+
# Map week_start_date → original WeekRef ISO strings so the
|
|
7973
|
+
# JSON serialization preserves the snapshot-stored tz format
|
|
7974
|
+
# (`+00:00` for UTC-anchored weeks) — TuiTrendRow's datetime
|
|
7975
|
+
# form re-localizes via parse_iso_datetime, which would emit
|
|
7976
|
+
# `+03:00` on a UTC+3 host and break byte-stability.
|
|
7977
|
+
#
|
|
7978
|
+
# Index by ``(week_start_date_iso, week_start_at_utc_instant)``
|
|
7979
|
+
# so ``_row_to_dict`` resolves a row's original WeekRef ISO
|
|
7980
|
+
# strings in O(1) — credited weeks share ``week_start_date`` so
|
|
7981
|
+
# the UTC-instant disambiguates them. The lookup key matches the
|
|
7982
|
+
# row-side derivation in ``_row_to_dict`` (UTC instant from the
|
|
7983
|
+
# parsed datetime).
|
|
7984
|
+
week_iso_by_key: dict[tuple[str, dt.datetime],
|
|
7985
|
+
tuple[str | None, str | None]] = {}
|
|
7986
|
+
for wr in weeks:
|
|
7987
|
+
if wr.week_start_at is None:
|
|
7988
|
+
continue
|
|
7989
|
+
try:
|
|
7990
|
+
wr_utc = parse_iso_datetime(
|
|
7991
|
+
wr.week_start_at, "wr.week_start_at",
|
|
7992
|
+
).astimezone(dt.timezone.utc)
|
|
7993
|
+
except ValueError:
|
|
7994
|
+
continue
|
|
7995
|
+
week_iso_by_key[(wr.week_start.isoformat(), wr_utc)] = (
|
|
7996
|
+
wr.week_start_at,
|
|
7997
|
+
wr.week_end_at,
|
|
7934
7998
|
)
|
|
7935
7999
|
|
|
7936
|
-
|
|
7937
|
-
#
|
|
7938
|
-
#
|
|
7939
|
-
#
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
else:
|
|
7972
|
-
as_of_dt = usage_captured_dt or cost_captured_dt
|
|
7973
|
-
as_of = as_of_dt.isoformat(timespec="seconds") if as_of_dt else None
|
|
7974
|
-
|
|
7975
|
-
row = {
|
|
7976
|
-
"weekStartDate": week_ref.week_start.isoformat(),
|
|
7977
|
-
"weekEndDate": week_ref.week_end.isoformat() if week_ref.week_end else None,
|
|
7978
|
-
"weekStartAt": week_ref.week_start_at,
|
|
7979
|
-
"weekEndAt": week_ref.week_end_at,
|
|
7980
|
-
"weeklyPercent": percent,
|
|
7981
|
-
"weeklyCostUSD": round(cost_usd, 9) if cost_usd is not None else None,
|
|
7982
|
-
"dollarsPerPercent": round(ratio, 9) if ratio is not None else None,
|
|
7983
|
-
"usageCapturedAt": usage_captured_at,
|
|
7984
|
-
"costCapturedAt": cost_captured_at,
|
|
7985
|
-
"asOf": as_of,
|
|
7986
|
-
"rangeStartIso": range_start_iso,
|
|
7987
|
-
"rangeEndIso": range_end_iso,
|
|
8000
|
+
def _row_to_dict(r):
|
|
8001
|
+
# Match this row's WeekRef by (week_start_date, UTC instant
|
|
8002
|
+
# of parsed week_start_at) — credited weeks share
|
|
8003
|
+
# week_start_date so we disambiguate via the UTC instant.
|
|
8004
|
+
wsd_str = (
|
|
8005
|
+
r.week_start_date.isoformat() if r.week_start_date else None
|
|
8006
|
+
)
|
|
8007
|
+
ws_at = ws_at_end = None
|
|
8008
|
+
if wsd_str is not None and r.week_start_at is not None:
|
|
8009
|
+
r_utc = r.week_start_at.astimezone(dt.timezone.utc)
|
|
8010
|
+
hit = week_iso_by_key.get((wsd_str, r_utc))
|
|
8011
|
+
if hit is not None:
|
|
8012
|
+
ws_at, ws_at_end = hit
|
|
8013
|
+
|
|
8014
|
+
d: dict[str, Any] = {
|
|
8015
|
+
"weekStartDate": wsd_str,
|
|
8016
|
+
"weekEndDate": (
|
|
8017
|
+
r.week_end_date.isoformat() if r.week_end_date else None
|
|
8018
|
+
),
|
|
8019
|
+
"weekStartAt": ws_at,
|
|
8020
|
+
"weekEndAt": ws_at_end,
|
|
8021
|
+
"weeklyPercent": r.used_pct,
|
|
8022
|
+
"weeklyCostUSD": (
|
|
8023
|
+
round(r.weekly_cost_usd, 9)
|
|
8024
|
+
if r.weekly_cost_usd is not None else None
|
|
8025
|
+
),
|
|
8026
|
+
"dollarsPerPercent": (
|
|
8027
|
+
round(r.dollars_per_percent, 9)
|
|
8028
|
+
if r.dollars_per_percent is not None else None
|
|
8029
|
+
),
|
|
8030
|
+
"usageCapturedAt": r.usage_captured_at,
|
|
8031
|
+
"costCapturedAt": r.cost_captured_at,
|
|
8032
|
+
"asOf": r.as_of,
|
|
8033
|
+
"rangeStartIso": r.range_start_iso,
|
|
8034
|
+
"rangeEndIso": r.range_end_iso,
|
|
7988
8035
|
}
|
|
7989
|
-
if
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
8036
|
+
if r.freshness:
|
|
8037
|
+
d["freshness"] = r.freshness
|
|
8038
|
+
return d
|
|
8039
|
+
|
|
8040
|
+
# view.rows is oldest-first; reverse for cmd_report's newest-first
|
|
8041
|
+
# JSON contract. Also need WeekRef-based current_row matching —
|
|
8042
|
+
# use weekRef key + week_start_at to disambiguate credited weeks.
|
|
8043
|
+
# We re-walk the original `weeks` list to map (key, week_start_at)
|
|
8044
|
+
# → the corresponding dict row.
|
|
8045
|
+
trend: list[dict[str, Any]] = []
|
|
8046
|
+
current_row: dict[str, Any] | None = None
|
|
8047
|
+
# `view.rows` order = chrono asc (oldest first). Build trend in
|
|
8048
|
+
# the reverse order (newest first) to match the historical
|
|
8049
|
+
# cmd_report contract.
|
|
8050
|
+
# The view's TuiTrendRow doesn't carry WeekRef.key directly; we
|
|
8051
|
+
# use (week_start_date, week_start_at) for the match — week_start_at
|
|
8052
|
+
# in TuiTrendRow is a parsed datetime, and current_ref carries
|
|
8053
|
+
# ISO strings.
|
|
8054
|
+
for r in reversed(view.rows):
|
|
8055
|
+
row = _row_to_dict(r)
|
|
7997
8056
|
trend.append(row)
|
|
7998
|
-
#
|
|
7999
|
-
#
|
|
8000
|
-
|
|
8001
|
-
# `_apply_reset_events_to_weekrefs` above so its
|
|
8002
|
-
# `week_start_at` reflects the post-credit segment's effective
|
|
8003
|
-
# start (or the original start for non-credit weeks). Match on
|
|
8004
|
-
# BOTH `key` AND `week_start_at` so the pre-credit ref doesn't
|
|
8005
|
-
# overwrite the post-credit row's selection on the second
|
|
8006
|
-
# iteration (last-write-wins on key-only equality picked the
|
|
8007
|
-
# wrong row on the user's live data).
|
|
8057
|
+
# Match against current_ref. Compare by week_start ISO date
|
|
8058
|
+
# AND week_start_at ISO string.
|
|
8059
|
+
week_start_at_iso = row["weekStartAt"]
|
|
8008
8060
|
if (
|
|
8009
|
-
|
|
8010
|
-
and
|
|
8061
|
+
r.week_start_date is not None
|
|
8062
|
+
and r.week_start_date.isoformat() == current_ref.key
|
|
8063
|
+
and week_start_at_iso == current_ref.week_start_at
|
|
8011
8064
|
):
|
|
8012
8065
|
current_row = row
|
|
8013
8066
|
|
|
@@ -8051,17 +8104,28 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
8051
8104
|
# detail isn't in the share spec scope). Same convention applies
|
|
8052
8105
|
# to other share-enabled subcommands (cmd_daily's --breakdown,
|
|
8053
8106
|
# etc.).
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8107
|
+
#
|
|
8108
|
+
# `view.rows` is already chronological (oldest-first), the
|
|
8109
|
+
# order the chart needs. period_start / period_end derived
|
|
8110
|
+
# from the view's oldest / newest rows.
|
|
8111
|
+
if view.rows:
|
|
8112
|
+
first_r = view.rows[0]
|
|
8113
|
+
last_r = view.rows[-1]
|
|
8114
|
+
first_wsd = (
|
|
8115
|
+
first_r.week_start_date.isoformat()
|
|
8116
|
+
if first_r.week_start_date else None
|
|
8117
|
+
)
|
|
8118
|
+
last_wed = (
|
|
8119
|
+
last_r.week_end_date.isoformat()
|
|
8120
|
+
if last_r.week_end_date else first_wsd
|
|
8121
|
+
)
|
|
8058
8122
|
period_start = _share_parse_date_to_dt(first_wsd, tz)
|
|
8059
8123
|
period_end = _share_parse_date_to_dt(last_wed, tz)
|
|
8060
8124
|
else:
|
|
8061
8125
|
period_start = period_end = _share_now_utc()
|
|
8062
8126
|
display_tz_str = _share_display_tz_label(tz)
|
|
8063
8127
|
snap = _build_report_snapshot(
|
|
8064
|
-
|
|
8128
|
+
view,
|
|
8065
8129
|
period_start=period_start,
|
|
8066
8130
|
period_end=period_end,
|
|
8067
8131
|
display_tz=display_tz_str,
|
|
@@ -12283,7 +12347,7 @@ def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
|
|
|
12283
12347
|
|
|
12284
12348
|
|
|
12285
12349
|
def _build_report_snapshot(
|
|
12286
|
-
|
|
12350
|
+
view: "TrendView",
|
|
12287
12351
|
*,
|
|
12288
12352
|
period_start: dt.datetime,
|
|
12289
12353
|
period_end: dt.datetime,
|
|
@@ -12294,21 +12358,23 @@ def _build_report_snapshot(
|
|
|
12294
12358
|
) -> "ShareSnapshot":
|
|
12295
12359
|
"""Build a ShareSnapshot for `cctally report`.
|
|
12296
12360
|
|
|
12297
|
-
|
|
12298
|
-
|
|
12299
|
-
(
|
|
12300
|
-
|
|
12301
|
-
`dollar_per_pct`) are NOT the actual `cmd_report` data shape — see
|
|
12302
|
-
Implementor 7 commit body for the deviation.
|
|
12361
|
+
Consumes the unified TrendView (spec §6.4). `view.rows` is the
|
|
12362
|
+
chronological (oldest-first) TuiTrendRow tuple — exactly the order
|
|
12363
|
+
the chart needs (BarChart polyline trends left→right with time);
|
|
12364
|
+
no reversal needed.
|
|
12303
12365
|
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12366
|
+
The earlier camelCase-dict workaround (recorded in the commit body
|
|
12367
|
+
of Implementor 7 of the share-v2 work) is obsolete: `TuiTrendRow`
|
|
12368
|
+
now carries 10 nullable extended fields (spec §4.1) and is the
|
|
12369
|
+
single typed shape that flows through both CLI report and share
|
|
12370
|
+
builders. Cmd_report's JSON serialization happens at the gate site
|
|
12371
|
+
(camelCase mapping done in cmd_report); this function reads
|
|
12372
|
+
attributes directly from the typed row.
|
|
12307
12373
|
|
|
12308
|
-
`theme` and `reveal_projects` flow into the subtitle directly so
|
|
12309
|
-
builder owns the canonical subtitle shape — no post-build
|
|
12310
|
-
the gate site. The forward-reference return type
|
|
12311
|
-
lazy-import boundary.
|
|
12374
|
+
`theme` and `reveal_projects` flow into the subtitle directly so
|
|
12375
|
+
the builder owns the canonical subtitle shape — no post-build
|
|
12376
|
+
re-stamp at the gate site. The forward-reference return type
|
|
12377
|
+
matches the kernel's lazy-import boundary.
|
|
12312
12378
|
"""
|
|
12313
12379
|
_lib_share = _share_load_lib()
|
|
12314
12380
|
columns = (
|
|
@@ -12318,12 +12384,11 @@ def _build_report_snapshot(
|
|
|
12318
12384
|
_lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
|
|
12319
12385
|
emphasis=True),
|
|
12320
12386
|
)
|
|
12387
|
+
rows = view.rows # oldest-first; matches chart's left→right walk.
|
|
12321
12388
|
snap_rows: list = []
|
|
12322
12389
|
chart_pts: list = []
|
|
12323
12390
|
for i, r in enumerate(rows):
|
|
12324
|
-
wsd = r.
|
|
12325
|
-
# `weekStartDate` is sourced from `week_ref.week_start.isoformat()`
|
|
12326
|
-
# — guaranteed `str`. Empty / unparseable falls back to em-dash.
|
|
12391
|
+
wsd = r.week_start_date.isoformat() if r.week_start_date else None
|
|
12327
12392
|
if isinstance(wsd, str) and wsd:
|
|
12328
12393
|
try:
|
|
12329
12394
|
week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
|
|
@@ -12336,9 +12401,9 @@ def _build_report_snapshot(
|
|
|
12336
12401
|
# share artifact follows the same convention. Coercing None to
|
|
12337
12402
|
# 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
|
|
12338
12403
|
# genuine zero, and would skew the avg / chart.
|
|
12339
|
-
used_pct_raw = r.
|
|
12340
|
-
cost_raw = r.
|
|
12341
|
-
dpp_raw = r.
|
|
12404
|
+
used_pct_raw = r.used_pct
|
|
12405
|
+
cost_raw = r.weekly_cost_usd
|
|
12406
|
+
dpp_raw = r.dollars_per_percent
|
|
12342
12407
|
snap_rows.append(_lib_share.Row(cells={
|
|
12343
12408
|
"week": _lib_share.TextCell(week_label),
|
|
12344
12409
|
"used": (
|
|
@@ -12367,10 +12432,17 @@ def _build_report_snapshot(
|
|
|
12367
12432
|
_lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
|
|
12368
12433
|
if len(chart_pts) >= 3 else None
|
|
12369
12434
|
)
|
|
12370
|
-
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12435
|
+
# Source the avg from the view (3-sample rule). Falls back to a
|
|
12436
|
+
# length-based average over the chart points for the <3-sample case
|
|
12437
|
+
# so the Totalled cell always renders something concrete; preserves
|
|
12438
|
+
# the prior $0.00 sentinel on empty data.
|
|
12439
|
+
if view.avg_dollars_per_pct is not None:
|
|
12440
|
+
avg_dpp = view.avg_dollars_per_pct
|
|
12441
|
+
else:
|
|
12442
|
+
avg_dpp = (
|
|
12443
|
+
sum(p.y_value for p in chart_pts) / len(chart_pts)
|
|
12444
|
+
if chart_pts else 0.0
|
|
12445
|
+
)
|
|
12374
12446
|
totals = (
|
|
12375
12447
|
_lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
|
|
12376
12448
|
)
|
|
@@ -12399,7 +12471,7 @@ def _build_report_snapshot(
|
|
|
12399
12471
|
|
|
12400
12472
|
|
|
12401
12473
|
def _build_daily_snapshot(
|
|
12402
|
-
|
|
12474
|
+
view: "DailyView",
|
|
12403
12475
|
*,
|
|
12404
12476
|
period_start: dt.datetime,
|
|
12405
12477
|
period_end: dt.datetime,
|
|
@@ -12410,10 +12482,11 @@ def _build_daily_snapshot(
|
|
|
12410
12482
|
) -> "ShareSnapshot":
|
|
12411
12483
|
"""Build a ShareSnapshot for `cctally daily`.
|
|
12412
12484
|
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12485
|
+
Consumes the unified DailyView (spec §6.1). `view.aggregated` is
|
|
12486
|
+
the gap-free BucketUsage tuple in newest-first order; we reverse
|
|
12487
|
+
here so BarChart bars render left-to-right chronologically.
|
|
12488
|
+
`view.total_cost_usd` is the pre-computed sum (replacing the
|
|
12489
|
+
prior inline re-totaling).
|
|
12417
12490
|
|
|
12418
12491
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12419
12492
|
`date` / `cost_usd` / `pct_of_week` / `top_model`):
|
|
@@ -12426,13 +12499,11 @@ def _build_daily_snapshot(
|
|
|
12426
12499
|
- `top_model` is the first entry of `model_breakdowns` (sorted by cost
|
|
12427
12500
|
desc per upstream ccusage parity); empty → "—".
|
|
12428
12501
|
|
|
12429
|
-
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12435
|
-
the gate site.
|
|
12502
|
+
`period_start` / `period_end` / `display_tz` are passed by the
|
|
12503
|
+
caller (they reflect the CLI's `--since` / `--until` window which
|
|
12504
|
+
may extend past the data window). `theme` and `reveal_projects`
|
|
12505
|
+
flow into the subtitle directly so the builder owns the canonical
|
|
12506
|
+
subtitle shape — no post-build re-stamp at the gate site.
|
|
12436
12507
|
"""
|
|
12437
12508
|
_lib_share = _share_load_lib()
|
|
12438
12509
|
columns = (
|
|
@@ -12444,9 +12515,11 @@ def _build_daily_snapshot(
|
|
|
12444
12515
|
_lib_share.ColumnSpec(key="top_model", label="Top Model",
|
|
12445
12516
|
align="left"),
|
|
12446
12517
|
)
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
12518
|
+
# Caller MUST pass rows in chronological order so the BarChart bars
|
|
12519
|
+
# line up left-to-right with time. view.aggregated is newest-first
|
|
12520
|
+
# (matches dashboard convention); reverse for chronological iteration.
|
|
12521
|
+
rows = list(reversed(view.aggregated))
|
|
12522
|
+
total_cost = view.total_cost_usd
|
|
12450
12523
|
|
|
12451
12524
|
snap_rows: list = []
|
|
12452
12525
|
chart_pts: list = []
|
|
@@ -12514,7 +12587,7 @@ def _build_daily_snapshot(
|
|
|
12514
12587
|
|
|
12515
12588
|
|
|
12516
12589
|
def _build_monthly_snapshot(
|
|
12517
|
-
|
|
12590
|
+
view: "MonthlyView",
|
|
12518
12591
|
*,
|
|
12519
12592
|
period_start: dt.datetime,
|
|
12520
12593
|
period_end: dt.datetime,
|
|
@@ -12525,9 +12598,9 @@ def _build_monthly_snapshot(
|
|
|
12525
12598
|
) -> "ShareSnapshot":
|
|
12526
12599
|
"""Build a ShareSnapshot for `cctally monthly`.
|
|
12527
12600
|
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12601
|
+
Consumes the unified MonthlyView (spec §6.2). `view.aggregated` is
|
|
12602
|
+
the gap-free BucketUsage tuple in newest-first order; we reverse
|
|
12603
|
+
so BarChart bars render left-to-right chronologically.
|
|
12531
12604
|
|
|
12532
12605
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12533
12606
|
`month` / `cost_usd` / `sessions`):
|
|
@@ -12540,14 +12613,15 @@ def _build_monthly_snapshot(
|
|
|
12540
12613
|
- `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
|
|
12541
12614
|
months, matching the plan's intent.
|
|
12542
12615
|
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
the
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12549
|
-
the gate site.
|
|
12616
|
+
`period_start` / `period_end` / `display_tz` are passed by the
|
|
12617
|
+
caller (the CLI's `--since` / `--until` window may extend past
|
|
12618
|
+
the data window). `theme` / `reveal_projects` flow into the
|
|
12619
|
+
subtitle directly so the builder owns the canonical subtitle
|
|
12620
|
+
shape — no post-build re-stamp at the gate site.
|
|
12550
12621
|
"""
|
|
12622
|
+
# Caller MUST pass rows in chronological order so the BarChart bars
|
|
12623
|
+
# line up left-to-right with time. view.aggregated is newest-first.
|
|
12624
|
+
rows = list(reversed(view.aggregated))
|
|
12551
12625
|
_lib_share = _share_load_lib()
|
|
12552
12626
|
columns = (
|
|
12553
12627
|
_lib_share.ColumnSpec(key="month", label="Month", align="left"),
|
|
@@ -12625,8 +12699,7 @@ def _build_monthly_snapshot(
|
|
|
12625
12699
|
|
|
12626
12700
|
|
|
12627
12701
|
def _build_weekly_snapshot(
|
|
12628
|
-
|
|
12629
|
-
overlay: list[tuple[float | None, float | None]],
|
|
12702
|
+
view: "WeeklyView",
|
|
12630
12703
|
*,
|
|
12631
12704
|
period_start: dt.datetime,
|
|
12632
12705
|
period_end: dt.datetime,
|
|
@@ -12638,16 +12711,19 @@ def _build_weekly_snapshot(
|
|
|
12638
12711
|
) -> "ShareSnapshot":
|
|
12639
12712
|
"""Build a ShareSnapshot for `cctally weekly`.
|
|
12640
12713
|
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
|
|
12644
|
-
|
|
12714
|
+
Consumes the unified WeeklyView (spec §6.3). `view.aggregated` is
|
|
12715
|
+
the gap-free BucketUsage tuple newest-first; `view.overlay` is the
|
|
12716
|
+
parallel `(used_pct, dollars_per_pct)` tuple. We reverse both for
|
|
12717
|
+
chronological iteration so BarChart bars render left-to-right
|
|
12718
|
+
with time.
|
|
12645
12719
|
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
|
|
12649
|
-
|
|
12650
|
-
|
|
12720
|
+
Each bucket carries `bucket` (week_start_date as "YYYY-MM-DD"),
|
|
12721
|
+
`cost_usd`, `total_tokens`, and `model_breakdowns` (list[dict]
|
|
12722
|
+
sorted by cost desc, each `{modelName, ..., cost}`). Either
|
|
12723
|
+
overlay component may be `None` for a week with no captured
|
|
12724
|
+
snapshot — surfaces in the snapshot row as a `0.0` PercentCell so
|
|
12725
|
+
the column stays aligned (matching the table renderer's "no data
|
|
12726
|
+
→ 0%" behavior).
|
|
12651
12727
|
|
|
12652
12728
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12653
12729
|
`week_start_date` / `used_pct` / `cost_usd` / `sessions` /
|
|
@@ -12671,14 +12747,14 @@ def _build_weekly_snapshot(
|
|
|
12671
12747
|
All model-axis iteration uses a single sorted list (`all_model_keys`)
|
|
12672
12748
|
so column / stack ordering is deterministic across runs.
|
|
12673
12749
|
|
|
12674
|
-
Caller MUST pass `rows` (and `overlay` aligned to it) in chronological
|
|
12675
|
-
order so the BarChart bars line up left-to-right with time. The gate
|
|
12676
|
-
site fires BEFORE the `--order desc` reversal in `cmd_weekly`.
|
|
12677
|
-
|
|
12678
12750
|
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
12679
12751
|
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12680
12752
|
the gate site.
|
|
12681
12753
|
"""
|
|
12754
|
+
# view.aggregated / view.overlay are newest-first; reverse for asc
|
|
12755
|
+
# so BarChart bars are chronological.
|
|
12756
|
+
rows = list(reversed(view.aggregated))
|
|
12757
|
+
overlay = list(reversed(view.overlay))
|
|
12682
12758
|
_lib_share = _share_load_lib()
|
|
12683
12759
|
columns_list: list = [
|
|
12684
12760
|
_lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
|
|
@@ -13346,7 +13422,7 @@ def _session_disambiguate_labels(
|
|
|
13346
13422
|
|
|
13347
13423
|
|
|
13348
13424
|
def _build_session_snapshot(
|
|
13349
|
-
|
|
13425
|
+
view: "SessionsView",
|
|
13350
13426
|
*,
|
|
13351
13427
|
period_start: dt.datetime,
|
|
13352
13428
|
period_end: dt.datetime,
|
|
@@ -13359,11 +13435,17 @@ def _build_session_snapshot(
|
|
|
13359
13435
|
) -> "ShareSnapshot":
|
|
13360
13436
|
"""Build a ShareSnapshot for `cctally session`.
|
|
13361
13437
|
|
|
13362
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13438
|
+
Consumes the unified ``SessionsView`` (spec §6.5). ``view.aggregated``
|
|
13439
|
+
is the ``ClaudeSessionUsage`` tuple — the shape this builder needs
|
|
13440
|
+
for ``source_paths`` / ``model_breakdowns`` / ``last_activity``
|
|
13441
|
+
(fields ``view.rows`` / ``TuiSessionRow`` doesn't carry). The
|
|
13442
|
+
in-memory shape is unchanged at the read boundary — only the
|
|
13443
|
+
parameter container differs.
|
|
13444
|
+
|
|
13445
|
+
Each ``ClaudeSessionUsage`` has: ``session_id`` (UUID),
|
|
13446
|
+
``project_path`` (filesystem path), ``cost_usd``,
|
|
13447
|
+
``last_activity`` (``dt.datetime``), ``models`` (first-seen-order
|
|
13448
|
+
``list[str]``), and the token aggregates.
|
|
13367
13449
|
|
|
13368
13450
|
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
13369
13451
|
`ProjectCell.label`, `ChartPoint.project_label`, and
|
|
@@ -13388,12 +13470,14 @@ def _build_session_snapshot(
|
|
|
13388
13470
|
not present on session data) to suffix `" (parent)"` on
|
|
13389
13471
|
collisions before the scrubber runs.
|
|
13390
13472
|
|
|
13391
|
-
Caller MUST pass
|
|
13392
|
-
|
|
13393
|
-
|
|
13394
|
-
|
|
13395
|
-
|
|
13396
|
-
|
|
13473
|
+
Caller MUST pass ``view`` whose ``aggregated`` tuple is already
|
|
13474
|
+
sorted in the desired order (``cmd_session`` keeps the
|
|
13475
|
+
aggregator's descending-by-last_activity sort); the builder
|
|
13476
|
+
re-sorts internally by descending cost so the chart's HBar bars
|
|
13477
|
+
rank consistently with the anonymization-mapping
|
|
13478
|
+
(``_build_anon_mapping`` also sorts by descending cost) — keeping
|
|
13479
|
+
``project-1`` aligned with the highest-cost bar in the chart even
|
|
13480
|
+
when the user asked for ``--order asc``.
|
|
13397
13481
|
|
|
13398
13482
|
`top_n`, when set (must be `>= 1`; caller validates), truncates
|
|
13399
13483
|
BOTH the table rows and the chart points to the top-N by cost.
|
|
@@ -13420,7 +13504,8 @@ def _build_session_snapshot(
|
|
|
13420
13504
|
# Sort by descending cost so the snapshot's chart-order matches the
|
|
13421
13505
|
# `_build_anon_mapping` sort key (also descending cost).
|
|
13422
13506
|
sorted_sessions = sorted(
|
|
13423
|
-
|
|
13507
|
+
view.aggregated,
|
|
13508
|
+
key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0),
|
|
13424
13509
|
)
|
|
13425
13510
|
# Apply --top-n truncation (caller validated >= 1). Truncation status
|
|
13426
13511
|
# gates the title shape below.
|