cctally 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -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 +289 -233
- 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
|
@@ -345,6 +345,20 @@ _aggregate_codex_sessions = _lib_aggregators._aggregate_codex_sessions
|
|
|
345
345
|
_session_path_parts = _lib_aggregators._session_path_parts
|
|
346
346
|
_aggregate_claude_sessions = _lib_aggregators._aggregate_claude_sessions
|
|
347
347
|
|
|
348
|
+
# View-model kernel — per-domain frozen dataclasses + builders.
|
|
349
|
+
# Spec: docs/superpowers/specs/2026-05-17-view-model-unification-design.md.
|
|
350
|
+
_lib_view_models = _load_sibling("_lib_view_models")
|
|
351
|
+
DailyView = _lib_view_models.DailyView
|
|
352
|
+
build_daily_view = _lib_view_models.build_daily_view
|
|
353
|
+
MonthlyView = _lib_view_models.MonthlyView
|
|
354
|
+
build_monthly_view = _lib_view_models.build_monthly_view
|
|
355
|
+
WeeklyView = _lib_view_models.WeeklyView
|
|
356
|
+
build_weekly_view = _lib_view_models.build_weekly_view
|
|
357
|
+
TrendView = _lib_view_models.TrendView
|
|
358
|
+
build_trend_view = _lib_view_models.build_trend_view
|
|
359
|
+
SessionsView = _lib_view_models.SessionsView
|
|
360
|
+
build_sessions_view = _lib_view_models.build_sessions_view
|
|
361
|
+
|
|
348
362
|
_lib_render = _load_sibling("_lib_render")
|
|
349
363
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
350
364
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
@@ -5222,8 +5236,19 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
5222
5236
|
# Collect entries.
|
|
5223
5237
|
all_entries = get_entries(range_start, range_end)
|
|
5224
5238
|
|
|
5225
|
-
#
|
|
5226
|
-
|
|
5239
|
+
# Build the unified daily view (spec §5.1: gap-free; the dashboard
|
|
5240
|
+
# heatmap's contiguous-window materialization stays at the dashboard
|
|
5241
|
+
# envelope adapter so CLI byte-stability is preserved). Consume
|
|
5242
|
+
# `view.aggregated` (BucketUsage tuple) for the CLI renderers — the
|
|
5243
|
+
# JSON shape's `bucket` / `model_breakdowns` / `models: list[str]`
|
|
5244
|
+
# fields live on BucketUsage, not on DailyPanelRow. The builder's
|
|
5245
|
+
# `_aggregate_daily` call is the same one we used inline.
|
|
5246
|
+
view = build_daily_view(all_entries, now_utc=_command_as_of(),
|
|
5247
|
+
display_tz=tz)
|
|
5248
|
+
# `_aggregate_daily` returned ascending order; build_daily_view stores
|
|
5249
|
+
# `aggregated` newest-first. CLI's default order is ascending, so
|
|
5250
|
+
# re-reverse to match the prior on-the-wire shape.
|
|
5251
|
+
days = list(reversed(view.aggregated))
|
|
5227
5252
|
|
|
5228
5253
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5229
5254
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5241,7 +5266,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
5241
5266
|
# subcommands (cmd_report's --detail, etc.).
|
|
5242
5267
|
display_tz_str = _share_display_tz_label(tz)
|
|
5243
5268
|
snap = _build_daily_snapshot(
|
|
5244
|
-
|
|
5269
|
+
view,
|
|
5245
5270
|
period_start=range_start,
|
|
5246
5271
|
period_end=range_end,
|
|
5247
5272
|
display_tz=display_tz_str,
|
|
@@ -5291,7 +5316,18 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
5291
5316
|
|
|
5292
5317
|
all_entries = get_entries(range_start, range_end)
|
|
5293
5318
|
|
|
5294
|
-
|
|
5319
|
+
# Build the unified monthly view (spec §5.2: drops boundary-spillover
|
|
5320
|
+
# bucket; computes delta_cost_pct internally). Consume
|
|
5321
|
+
# `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
|
|
5322
|
+
# stability — `_bucket_to_json` reads BucketUsage fields not present
|
|
5323
|
+
# on MonthlyPeriodRow.
|
|
5324
|
+
#
|
|
5325
|
+
# Pass a large `n` so the CLI's `--since`/`--until` window controls
|
|
5326
|
+
# how many months render (the dashboard caps at n=12; CLI doesn't).
|
|
5327
|
+
view = build_monthly_view(all_entries, now_utc=_command_as_of(),
|
|
5328
|
+
n=10**6, display_tz=tz)
|
|
5329
|
+
# The view stores `aggregated` newest-first; CLI default is asc.
|
|
5330
|
+
months = list(reversed(view.aggregated))
|
|
5295
5331
|
|
|
5296
5332
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5297
5333
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5306,7 +5342,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
5306
5342
|
# share spec scope). Same convention as cmd_daily / cmd_report.
|
|
5307
5343
|
display_tz_str = _share_display_tz_label(tz)
|
|
5308
5344
|
snap = _build_monthly_snapshot(
|
|
5309
|
-
|
|
5345
|
+
view,
|
|
5310
5346
|
period_start=range_start,
|
|
5311
5347
|
period_end=range_end,
|
|
5312
5348
|
display_tz=display_tz_str,
|
|
@@ -5371,15 +5407,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5371
5407
|
else:
|
|
5372
5408
|
fetch_start = range_start
|
|
5373
5409
|
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
|
-
#
|
|
5410
|
+
|
|
5383
5411
|
# Bound the usage-snapshot lookup to `<= range_end` so historical
|
|
5384
5412
|
# `--until <past date>` queries pick the usage% that was current at
|
|
5385
5413
|
# the end of the requested window rather than the globally latest
|
|
@@ -5395,25 +5423,19 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5395
5423
|
.isoformat()
|
|
5396
5424
|
.replace("+00:00", "Z")
|
|
5397
5425
|
)
|
|
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))
|
|
5426
|
+
|
|
5427
|
+
# Build the unified weekly view (spec §5.3): runs _aggregate_weekly,
|
|
5428
|
+
# overlays weekly_usage_snapshots per WeekRef. view.aggregated is
|
|
5429
|
+
# the BucketUsage tuple newest-first; view.overlay is the parallel
|
|
5430
|
+
# (used_pct, dollar_per_pct) tuple. We reverse both for CLI's
|
|
5431
|
+
# default asc rendering so the existing renderer's len-equality
|
|
5432
|
+
# assertions stay aligned.
|
|
5433
|
+
view = build_weekly_view(
|
|
5434
|
+
conn, all_entries, weeks=weeks, now_utc=now_utc,
|
|
5435
|
+
display_tz=args._resolved_tz, as_of_utc=as_of_utc,
|
|
5436
|
+
)
|
|
5437
|
+
buckets = list(reversed(view.aggregated))
|
|
5438
|
+
overlay = list(reversed(view.overlay))
|
|
5417
5439
|
|
|
5418
5440
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5419
5441
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -5428,7 +5450,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
5428
5450
|
if getattr(args, "format", None):
|
|
5429
5451
|
display_tz_str = _share_display_tz_label(args._resolved_tz)
|
|
5430
5452
|
snap = _build_weekly_snapshot(
|
|
5431
|
-
|
|
5453
|
+
view,
|
|
5432
5454
|
period_start=range_start,
|
|
5433
5455
|
period_end=range_end,
|
|
5434
5456
|
display_tz=display_tz_str,
|
|
@@ -6506,7 +6528,20 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
6506
6528
|
range_start, range_end = range
|
|
6507
6529
|
|
|
6508
6530
|
entries = get_claude_session_entries(range_start, range_end)
|
|
6509
|
-
|
|
6531
|
+
# Unified view-model kernel (spec §6.5). `limit=None` keeps the
|
|
6532
|
+
# full aggregator output — `cctally session` has no `--limit` flag
|
|
6533
|
+
# and emits every session in the requested range. `view.aggregated`
|
|
6534
|
+
# is the `list[ClaudeSessionUsage]` shape the legacy CLI / share
|
|
6535
|
+
# renderers consume (table, --json, share-snapshot); `view.rows`
|
|
6536
|
+
# is the typed `TuiSessionRow` tuple reserved for the TUI /
|
|
6537
|
+
# dashboard wiring in Task 15 / 16. Keeping both shapes parallel
|
|
6538
|
+
# at the builder preserves the resumed-session merge invariant
|
|
6539
|
+
# documented in CLAUDE.md (one sessionId across multiple JSONL
|
|
6540
|
+
# files collapses to ONE entry in BOTH tuples).
|
|
6541
|
+
view = build_sessions_view(
|
|
6542
|
+
entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
|
|
6543
|
+
)
|
|
6544
|
+
sessions = list(view.aggregated)
|
|
6510
6545
|
|
|
6511
6546
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
6512
6547
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -6540,7 +6575,7 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
6540
6575
|
# cmd_daily / cmd_project.
|
|
6541
6576
|
display_tz_str = _share_display_tz_label(tz)
|
|
6542
6577
|
snap = _build_session_snapshot(
|
|
6543
|
-
|
|
6578
|
+
view,
|
|
6544
6579
|
period_start=range_start,
|
|
6545
6580
|
period_end=range_end,
|
|
6546
6581
|
display_tz=display_tz_str,
|
|
@@ -7874,7 +7909,7 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
7874
7909
|
we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
7875
7910
|
)
|
|
7876
7911
|
snap = _build_report_snapshot(
|
|
7877
|
-
|
|
7912
|
+
TrendView(),
|
|
7878
7913
|
period_start=ws_dt,
|
|
7879
7914
|
period_end=we_dt,
|
|
7880
7915
|
display_tz=display_tz_str,
|
|
@@ -7890,124 +7925,113 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
7890
7925
|
print("No data yet. Add record-usage to your status line script (see record-usage --help).")
|
|
7891
7926
|
return 0
|
|
7892
7927
|
|
|
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
|
-
|
|
7928
|
+
# Build the unified trend view (spec §5.4). `build_trend_view`
|
|
7929
|
+
# owns the per-row construction, including:
|
|
7930
|
+
# - get_latest_usage_for_week with split-key as_of_utc pinning
|
|
7931
|
+
# for credited weeks (Bug D / round-3 Bug B parity)
|
|
7932
|
+
# - _week_ref_has_reset_event → _compute_cost_for_weekref bypass
|
|
7933
|
+
# for reset-affected weeks
|
|
7934
|
+
# - freshness sub-dict derivation
|
|
7935
|
+
# - 3-sample-rule average
|
|
7936
|
+
# Note: build_trend_view returns rows oldest-first (chronological);
|
|
7937
|
+
# cmd_report's JSON contract is newest-first to mirror
|
|
7938
|
+
# get_recent_weeks's order — we reverse below.
|
|
7939
|
+
view = build_trend_view(conn, now_utc=_command_as_of(), n=args.weeks,
|
|
7940
|
+
display_tz=tz)
|
|
7941
|
+
# Serialize TuiTrendRow → today's camelCase keys. Order:
|
|
7942
|
+
# newest-first (matches the prior cmd_report behavior).
|
|
7943
|
+
# Map week_start_date → original WeekRef ISO strings so the
|
|
7944
|
+
# JSON serialization preserves the snapshot-stored tz format
|
|
7945
|
+
# (`+00:00` for UTC-anchored weeks) — TuiTrendRow's datetime
|
|
7946
|
+
# form re-localizes via parse_iso_datetime, which would emit
|
|
7947
|
+
# `+03:00` on a UTC+3 host and break byte-stability.
|
|
7948
|
+
#
|
|
7949
|
+
# Index by ``(week_start_date_iso, week_start_at_utc_instant)``
|
|
7950
|
+
# so ``_row_to_dict`` resolves a row's original WeekRef ISO
|
|
7951
|
+
# strings in O(1) — credited weeks share ``week_start_date`` so
|
|
7952
|
+
# the UTC-instant disambiguates them. The lookup key matches the
|
|
7953
|
+
# row-side derivation in ``_row_to_dict`` (UTC instant from the
|
|
7954
|
+
# parsed datetime).
|
|
7955
|
+
week_iso_by_key: dict[tuple[str, dt.datetime],
|
|
7956
|
+
tuple[str | None, str | None]] = {}
|
|
7957
|
+
for wr in weeks:
|
|
7958
|
+
if wr.week_start_at is None:
|
|
7959
|
+
continue
|
|
7960
|
+
try:
|
|
7961
|
+
wr_utc = parse_iso_datetime(
|
|
7962
|
+
wr.week_start_at, "wr.week_start_at",
|
|
7963
|
+
).astimezone(dt.timezone.utc)
|
|
7964
|
+
except ValueError:
|
|
7965
|
+
continue
|
|
7966
|
+
week_iso_by_key[(wr.week_start.isoformat(), wr_utc)] = (
|
|
7967
|
+
wr.week_start_at,
|
|
7968
|
+
wr.week_end_at,
|
|
7934
7969
|
)
|
|
7935
7970
|
|
|
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,
|
|
7971
|
+
def _row_to_dict(r):
|
|
7972
|
+
# Match this row's WeekRef by (week_start_date, UTC instant
|
|
7973
|
+
# of parsed week_start_at) — credited weeks share
|
|
7974
|
+
# week_start_date so we disambiguate via the UTC instant.
|
|
7975
|
+
wsd_str = (
|
|
7976
|
+
r.week_start_date.isoformat() if r.week_start_date else None
|
|
7977
|
+
)
|
|
7978
|
+
ws_at = ws_at_end = None
|
|
7979
|
+
if wsd_str is not None and r.week_start_at is not None:
|
|
7980
|
+
r_utc = r.week_start_at.astimezone(dt.timezone.utc)
|
|
7981
|
+
hit = week_iso_by_key.get((wsd_str, r_utc))
|
|
7982
|
+
if hit is not None:
|
|
7983
|
+
ws_at, ws_at_end = hit
|
|
7984
|
+
|
|
7985
|
+
d: dict[str, Any] = {
|
|
7986
|
+
"weekStartDate": wsd_str,
|
|
7987
|
+
"weekEndDate": (
|
|
7988
|
+
r.week_end_date.isoformat() if r.week_end_date else None
|
|
7989
|
+
),
|
|
7990
|
+
"weekStartAt": ws_at,
|
|
7991
|
+
"weekEndAt": ws_at_end,
|
|
7992
|
+
"weeklyPercent": r.used_pct,
|
|
7993
|
+
"weeklyCostUSD": (
|
|
7994
|
+
round(r.weekly_cost_usd, 9)
|
|
7995
|
+
if r.weekly_cost_usd is not None else None
|
|
7996
|
+
),
|
|
7997
|
+
"dollarsPerPercent": (
|
|
7998
|
+
round(r.dollars_per_percent, 9)
|
|
7999
|
+
if r.dollars_per_percent is not None else None
|
|
8000
|
+
),
|
|
8001
|
+
"usageCapturedAt": r.usage_captured_at,
|
|
8002
|
+
"costCapturedAt": r.cost_captured_at,
|
|
8003
|
+
"asOf": r.as_of,
|
|
8004
|
+
"rangeStartIso": r.range_start_iso,
|
|
8005
|
+
"rangeEndIso": r.range_end_iso,
|
|
7988
8006
|
}
|
|
7989
|
-
if
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
8007
|
+
if r.freshness:
|
|
8008
|
+
d["freshness"] = r.freshness
|
|
8009
|
+
return d
|
|
8010
|
+
|
|
8011
|
+
# view.rows is oldest-first; reverse for cmd_report's newest-first
|
|
8012
|
+
# JSON contract. Also need WeekRef-based current_row matching —
|
|
8013
|
+
# use weekRef key + week_start_at to disambiguate credited weeks.
|
|
8014
|
+
# We re-walk the original `weeks` list to map (key, week_start_at)
|
|
8015
|
+
# → the corresponding dict row.
|
|
8016
|
+
trend: list[dict[str, Any]] = []
|
|
8017
|
+
current_row: dict[str, Any] | None = None
|
|
8018
|
+
# `view.rows` order = chrono asc (oldest first). Build trend in
|
|
8019
|
+
# the reverse order (newest first) to match the historical
|
|
8020
|
+
# cmd_report contract.
|
|
8021
|
+
# The view's TuiTrendRow doesn't carry WeekRef.key directly; we
|
|
8022
|
+
# use (week_start_date, week_start_at) for the match — week_start_at
|
|
8023
|
+
# in TuiTrendRow is a parsed datetime, and current_ref carries
|
|
8024
|
+
# ISO strings.
|
|
8025
|
+
for r in reversed(view.rows):
|
|
8026
|
+
row = _row_to_dict(r)
|
|
7997
8027
|
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).
|
|
8028
|
+
# Match against current_ref. Compare by week_start ISO date
|
|
8029
|
+
# AND week_start_at ISO string.
|
|
8030
|
+
week_start_at_iso = row["weekStartAt"]
|
|
8008
8031
|
if (
|
|
8009
|
-
|
|
8010
|
-
and
|
|
8032
|
+
r.week_start_date is not None
|
|
8033
|
+
and r.week_start_date.isoformat() == current_ref.key
|
|
8034
|
+
and week_start_at_iso == current_ref.week_start_at
|
|
8011
8035
|
):
|
|
8012
8036
|
current_row = row
|
|
8013
8037
|
|
|
@@ -8051,17 +8075,28 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
8051
8075
|
# detail isn't in the share spec scope). Same convention applies
|
|
8052
8076
|
# to other share-enabled subcommands (cmd_daily's --breakdown,
|
|
8053
8077
|
# etc.).
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8078
|
+
#
|
|
8079
|
+
# `view.rows` is already chronological (oldest-first), the
|
|
8080
|
+
# order the chart needs. period_start / period_end derived
|
|
8081
|
+
# from the view's oldest / newest rows.
|
|
8082
|
+
if view.rows:
|
|
8083
|
+
first_r = view.rows[0]
|
|
8084
|
+
last_r = view.rows[-1]
|
|
8085
|
+
first_wsd = (
|
|
8086
|
+
first_r.week_start_date.isoformat()
|
|
8087
|
+
if first_r.week_start_date else None
|
|
8088
|
+
)
|
|
8089
|
+
last_wed = (
|
|
8090
|
+
last_r.week_end_date.isoformat()
|
|
8091
|
+
if last_r.week_end_date else first_wsd
|
|
8092
|
+
)
|
|
8058
8093
|
period_start = _share_parse_date_to_dt(first_wsd, tz)
|
|
8059
8094
|
period_end = _share_parse_date_to_dt(last_wed, tz)
|
|
8060
8095
|
else:
|
|
8061
8096
|
period_start = period_end = _share_now_utc()
|
|
8062
8097
|
display_tz_str = _share_display_tz_label(tz)
|
|
8063
8098
|
snap = _build_report_snapshot(
|
|
8064
|
-
|
|
8099
|
+
view,
|
|
8065
8100
|
period_start=period_start,
|
|
8066
8101
|
period_end=period_end,
|
|
8067
8102
|
display_tz=display_tz_str,
|
|
@@ -12283,7 +12318,7 @@ def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
|
|
|
12283
12318
|
|
|
12284
12319
|
|
|
12285
12320
|
def _build_report_snapshot(
|
|
12286
|
-
|
|
12321
|
+
view: "TrendView",
|
|
12287
12322
|
*,
|
|
12288
12323
|
period_start: dt.datetime,
|
|
12289
12324
|
period_end: dt.datetime,
|
|
@@ -12294,21 +12329,23 @@ def _build_report_snapshot(
|
|
|
12294
12329
|
) -> "ShareSnapshot":
|
|
12295
12330
|
"""Build a ShareSnapshot for `cctally report`.
|
|
12296
12331
|
|
|
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.
|
|
12332
|
+
Consumes the unified TrendView (spec §6.4). `view.rows` is the
|
|
12333
|
+
chronological (oldest-first) TuiTrendRow tuple — exactly the order
|
|
12334
|
+
the chart needs (BarChart polyline trends left→right with time);
|
|
12335
|
+
no reversal needed.
|
|
12303
12336
|
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12337
|
+
The earlier camelCase-dict workaround (recorded in the commit body
|
|
12338
|
+
of Implementor 7 of the share-v2 work) is obsolete: `TuiTrendRow`
|
|
12339
|
+
now carries 10 nullable extended fields (spec §4.1) and is the
|
|
12340
|
+
single typed shape that flows through both CLI report and share
|
|
12341
|
+
builders. Cmd_report's JSON serialization happens at the gate site
|
|
12342
|
+
(camelCase mapping done in cmd_report); this function reads
|
|
12343
|
+
attributes directly from the typed row.
|
|
12307
12344
|
|
|
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.
|
|
12345
|
+
`theme` and `reveal_projects` flow into the subtitle directly so
|
|
12346
|
+
the builder owns the canonical subtitle shape — no post-build
|
|
12347
|
+
re-stamp at the gate site. The forward-reference return type
|
|
12348
|
+
matches the kernel's lazy-import boundary.
|
|
12312
12349
|
"""
|
|
12313
12350
|
_lib_share = _share_load_lib()
|
|
12314
12351
|
columns = (
|
|
@@ -12318,12 +12355,11 @@ def _build_report_snapshot(
|
|
|
12318
12355
|
_lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
|
|
12319
12356
|
emphasis=True),
|
|
12320
12357
|
)
|
|
12358
|
+
rows = view.rows # oldest-first; matches chart's left→right walk.
|
|
12321
12359
|
snap_rows: list = []
|
|
12322
12360
|
chart_pts: list = []
|
|
12323
12361
|
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.
|
|
12362
|
+
wsd = r.week_start_date.isoformat() if r.week_start_date else None
|
|
12327
12363
|
if isinstance(wsd, str) and wsd:
|
|
12328
12364
|
try:
|
|
12329
12365
|
week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
|
|
@@ -12336,9 +12372,9 @@ def _build_report_snapshot(
|
|
|
12336
12372
|
# share artifact follows the same convention. Coercing None to
|
|
12337
12373
|
# 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
|
|
12338
12374
|
# genuine zero, and would skew the avg / chart.
|
|
12339
|
-
used_pct_raw = r.
|
|
12340
|
-
cost_raw = r.
|
|
12341
|
-
dpp_raw = r.
|
|
12375
|
+
used_pct_raw = r.used_pct
|
|
12376
|
+
cost_raw = r.weekly_cost_usd
|
|
12377
|
+
dpp_raw = r.dollars_per_percent
|
|
12342
12378
|
snap_rows.append(_lib_share.Row(cells={
|
|
12343
12379
|
"week": _lib_share.TextCell(week_label),
|
|
12344
12380
|
"used": (
|
|
@@ -12367,10 +12403,17 @@ def _build_report_snapshot(
|
|
|
12367
12403
|
_lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
|
|
12368
12404
|
if len(chart_pts) >= 3 else None
|
|
12369
12405
|
)
|
|
12370
|
-
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12406
|
+
# Source the avg from the view (3-sample rule). Falls back to a
|
|
12407
|
+
# length-based average over the chart points for the <3-sample case
|
|
12408
|
+
# so the Totalled cell always renders something concrete; preserves
|
|
12409
|
+
# the prior $0.00 sentinel on empty data.
|
|
12410
|
+
if view.avg_dollars_per_pct is not None:
|
|
12411
|
+
avg_dpp = view.avg_dollars_per_pct
|
|
12412
|
+
else:
|
|
12413
|
+
avg_dpp = (
|
|
12414
|
+
sum(p.y_value for p in chart_pts) / len(chart_pts)
|
|
12415
|
+
if chart_pts else 0.0
|
|
12416
|
+
)
|
|
12374
12417
|
totals = (
|
|
12375
12418
|
_lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
|
|
12376
12419
|
)
|
|
@@ -12399,7 +12442,7 @@ def _build_report_snapshot(
|
|
|
12399
12442
|
|
|
12400
12443
|
|
|
12401
12444
|
def _build_daily_snapshot(
|
|
12402
|
-
|
|
12445
|
+
view: "DailyView",
|
|
12403
12446
|
*,
|
|
12404
12447
|
period_start: dt.datetime,
|
|
12405
12448
|
period_end: dt.datetime,
|
|
@@ -12410,10 +12453,11 @@ def _build_daily_snapshot(
|
|
|
12410
12453
|
) -> "ShareSnapshot":
|
|
12411
12454
|
"""Build a ShareSnapshot for `cctally daily`.
|
|
12412
12455
|
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12456
|
+
Consumes the unified DailyView (spec §6.1). `view.aggregated` is
|
|
12457
|
+
the gap-free BucketUsage tuple in newest-first order; we reverse
|
|
12458
|
+
here so BarChart bars render left-to-right chronologically.
|
|
12459
|
+
`view.total_cost_usd` is the pre-computed sum (replacing the
|
|
12460
|
+
prior inline re-totaling).
|
|
12417
12461
|
|
|
12418
12462
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12419
12463
|
`date` / `cost_usd` / `pct_of_week` / `top_model`):
|
|
@@ -12426,13 +12470,11 @@ def _build_daily_snapshot(
|
|
|
12426
12470
|
- `top_model` is the first entry of `model_breakdowns` (sorted by cost
|
|
12427
12471
|
desc per upstream ccusage parity); empty → "—".
|
|
12428
12472
|
|
|
12429
|
-
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12435
|
-
the gate site.
|
|
12473
|
+
`period_start` / `period_end` / `display_tz` are passed by the
|
|
12474
|
+
caller (they reflect the CLI's `--since` / `--until` window which
|
|
12475
|
+
may extend past the data window). `theme` and `reveal_projects`
|
|
12476
|
+
flow into the subtitle directly so the builder owns the canonical
|
|
12477
|
+
subtitle shape — no post-build re-stamp at the gate site.
|
|
12436
12478
|
"""
|
|
12437
12479
|
_lib_share = _share_load_lib()
|
|
12438
12480
|
columns = (
|
|
@@ -12444,9 +12486,11 @@ def _build_daily_snapshot(
|
|
|
12444
12486
|
_lib_share.ColumnSpec(key="top_model", label="Top Model",
|
|
12445
12487
|
align="left"),
|
|
12446
12488
|
)
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
12489
|
+
# Caller MUST pass rows in chronological order so the BarChart bars
|
|
12490
|
+
# line up left-to-right with time. view.aggregated is newest-first
|
|
12491
|
+
# (matches dashboard convention); reverse for chronological iteration.
|
|
12492
|
+
rows = list(reversed(view.aggregated))
|
|
12493
|
+
total_cost = view.total_cost_usd
|
|
12450
12494
|
|
|
12451
12495
|
snap_rows: list = []
|
|
12452
12496
|
chart_pts: list = []
|
|
@@ -12514,7 +12558,7 @@ def _build_daily_snapshot(
|
|
|
12514
12558
|
|
|
12515
12559
|
|
|
12516
12560
|
def _build_monthly_snapshot(
|
|
12517
|
-
|
|
12561
|
+
view: "MonthlyView",
|
|
12518
12562
|
*,
|
|
12519
12563
|
period_start: dt.datetime,
|
|
12520
12564
|
period_end: dt.datetime,
|
|
@@ -12525,9 +12569,9 @@ def _build_monthly_snapshot(
|
|
|
12525
12569
|
) -> "ShareSnapshot":
|
|
12526
12570
|
"""Build a ShareSnapshot for `cctally monthly`.
|
|
12527
12571
|
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12572
|
+
Consumes the unified MonthlyView (spec §6.2). `view.aggregated` is
|
|
12573
|
+
the gap-free BucketUsage tuple in newest-first order; we reverse
|
|
12574
|
+
so BarChart bars render left-to-right chronologically.
|
|
12531
12575
|
|
|
12532
12576
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12533
12577
|
`month` / `cost_usd` / `sessions`):
|
|
@@ -12540,14 +12584,15 @@ def _build_monthly_snapshot(
|
|
|
12540
12584
|
- `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
|
|
12541
12585
|
months, matching the plan's intent.
|
|
12542
12586
|
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
the
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12549
|
-
the gate site.
|
|
12587
|
+
`period_start` / `period_end` / `display_tz` are passed by the
|
|
12588
|
+
caller (the CLI's `--since` / `--until` window may extend past
|
|
12589
|
+
the data window). `theme` / `reveal_projects` flow into the
|
|
12590
|
+
subtitle directly so the builder owns the canonical subtitle
|
|
12591
|
+
shape — no post-build re-stamp at the gate site.
|
|
12550
12592
|
"""
|
|
12593
|
+
# Caller MUST pass rows in chronological order so the BarChart bars
|
|
12594
|
+
# line up left-to-right with time. view.aggregated is newest-first.
|
|
12595
|
+
rows = list(reversed(view.aggregated))
|
|
12551
12596
|
_lib_share = _share_load_lib()
|
|
12552
12597
|
columns = (
|
|
12553
12598
|
_lib_share.ColumnSpec(key="month", label="Month", align="left"),
|
|
@@ -12625,8 +12670,7 @@ def _build_monthly_snapshot(
|
|
|
12625
12670
|
|
|
12626
12671
|
|
|
12627
12672
|
def _build_weekly_snapshot(
|
|
12628
|
-
|
|
12629
|
-
overlay: list[tuple[float | None, float | None]],
|
|
12673
|
+
view: "WeeklyView",
|
|
12630
12674
|
*,
|
|
12631
12675
|
period_start: dt.datetime,
|
|
12632
12676
|
period_end: dt.datetime,
|
|
@@ -12638,16 +12682,19 @@ def _build_weekly_snapshot(
|
|
|
12638
12682
|
) -> "ShareSnapshot":
|
|
12639
12683
|
"""Build a ShareSnapshot for `cctally weekly`.
|
|
12640
12684
|
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
|
|
12644
|
-
|
|
12685
|
+
Consumes the unified WeeklyView (spec §6.3). `view.aggregated` is
|
|
12686
|
+
the gap-free BucketUsage tuple newest-first; `view.overlay` is the
|
|
12687
|
+
parallel `(used_pct, dollars_per_pct)` tuple. We reverse both for
|
|
12688
|
+
chronological iteration so BarChart bars render left-to-right
|
|
12689
|
+
with time.
|
|
12645
12690
|
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
|
|
12649
|
-
|
|
12650
|
-
|
|
12691
|
+
Each bucket carries `bucket` (week_start_date as "YYYY-MM-DD"),
|
|
12692
|
+
`cost_usd`, `total_tokens`, and `model_breakdowns` (list[dict]
|
|
12693
|
+
sorted by cost desc, each `{modelName, ..., cost}`). Either
|
|
12694
|
+
overlay component may be `None` for a week with no captured
|
|
12695
|
+
snapshot — surfaces in the snapshot row as a `0.0` PercentCell so
|
|
12696
|
+
the column stays aligned (matching the table renderer's "no data
|
|
12697
|
+
→ 0%" behavior).
|
|
12651
12698
|
|
|
12652
12699
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
12653
12700
|
`week_start_date` / `used_pct` / `cost_usd` / `sessions` /
|
|
@@ -12671,14 +12718,14 @@ def _build_weekly_snapshot(
|
|
|
12671
12718
|
All model-axis iteration uses a single sorted list (`all_model_keys`)
|
|
12672
12719
|
so column / stack ordering is deterministic across runs.
|
|
12673
12720
|
|
|
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
12721
|
`theme` and `reveal_projects` flow into the subtitle directly so the
|
|
12679
12722
|
builder owns the canonical subtitle shape — no post-build re-stamp at
|
|
12680
12723
|
the gate site.
|
|
12681
12724
|
"""
|
|
12725
|
+
# view.aggregated / view.overlay are newest-first; reverse for asc
|
|
12726
|
+
# so BarChart bars are chronological.
|
|
12727
|
+
rows = list(reversed(view.aggregated))
|
|
12728
|
+
overlay = list(reversed(view.overlay))
|
|
12682
12729
|
_lib_share = _share_load_lib()
|
|
12683
12730
|
columns_list: list = [
|
|
12684
12731
|
_lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
|
|
@@ -13346,7 +13393,7 @@ def _session_disambiguate_labels(
|
|
|
13346
13393
|
|
|
13347
13394
|
|
|
13348
13395
|
def _build_session_snapshot(
|
|
13349
|
-
|
|
13396
|
+
view: "SessionsView",
|
|
13350
13397
|
*,
|
|
13351
13398
|
period_start: dt.datetime,
|
|
13352
13399
|
period_end: dt.datetime,
|
|
@@ -13359,11 +13406,17 @@ def _build_session_snapshot(
|
|
|
13359
13406
|
) -> "ShareSnapshot":
|
|
13360
13407
|
"""Build a ShareSnapshot for `cctally session`.
|
|
13361
13408
|
|
|
13362
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13409
|
+
Consumes the unified ``SessionsView`` (spec §6.5). ``view.aggregated``
|
|
13410
|
+
is the ``ClaudeSessionUsage`` tuple — the shape this builder needs
|
|
13411
|
+
for ``source_paths`` / ``model_breakdowns`` / ``last_activity``
|
|
13412
|
+
(fields ``view.rows`` / ``TuiSessionRow`` doesn't carry). The
|
|
13413
|
+
in-memory shape is unchanged at the read boundary — only the
|
|
13414
|
+
parameter container differs.
|
|
13415
|
+
|
|
13416
|
+
Each ``ClaudeSessionUsage`` has: ``session_id`` (UUID),
|
|
13417
|
+
``project_path`` (filesystem path), ``cost_usd``,
|
|
13418
|
+
``last_activity`` (``dt.datetime``), ``models`` (first-seen-order
|
|
13419
|
+
``list[str]``), and the token aggregates.
|
|
13367
13420
|
|
|
13368
13421
|
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
13369
13422
|
`ProjectCell.label`, `ChartPoint.project_label`, and
|
|
@@ -13388,12 +13441,14 @@ def _build_session_snapshot(
|
|
|
13388
13441
|
not present on session data) to suffix `" (parent)"` on
|
|
13389
13442
|
collisions before the scrubber runs.
|
|
13390
13443
|
|
|
13391
|
-
Caller MUST pass
|
|
13392
|
-
|
|
13393
|
-
|
|
13394
|
-
|
|
13395
|
-
|
|
13396
|
-
|
|
13444
|
+
Caller MUST pass ``view`` whose ``aggregated`` tuple is already
|
|
13445
|
+
sorted in the desired order (``cmd_session`` keeps the
|
|
13446
|
+
aggregator's descending-by-last_activity sort); the builder
|
|
13447
|
+
re-sorts internally by descending cost so the chart's HBar bars
|
|
13448
|
+
rank consistently with the anonymization-mapping
|
|
13449
|
+
(``_build_anon_mapping`` also sorts by descending cost) — keeping
|
|
13450
|
+
``project-1`` aligned with the highest-cost bar in the chart even
|
|
13451
|
+
when the user asked for ``--order asc``.
|
|
13397
13452
|
|
|
13398
13453
|
`top_n`, when set (must be `>= 1`; caller validates), truncates
|
|
13399
13454
|
BOTH the table rows and the chart points to the top-N by cost.
|
|
@@ -13420,7 +13475,8 @@ def _build_session_snapshot(
|
|
|
13420
13475
|
# Sort by descending cost so the snapshot's chart-order matches the
|
|
13421
13476
|
# `_build_anon_mapping` sort key (also descending cost).
|
|
13422
13477
|
sorted_sessions = sorted(
|
|
13423
|
-
|
|
13478
|
+
view.aggregated,
|
|
13479
|
+
key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0),
|
|
13424
13480
|
)
|
|
13425
13481
|
# Apply --top-n truncation (caller validated >= 1). Truncation status
|
|
13426
13482
|
# gates the title shape below.
|