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/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((len(r[idx]) for r in sanitized_rows), default=0)
2704
- widths.append(max(len(header), max_cell))
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.rjust(width)
2750
+ return pad + text
2709
2751
  if align == "center":
2710
- return text.center(width)
2711
- return text.ljust(width)
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
- # Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
5226
- days = _aggregate_daily(all_entries, mode="auto", tz=tz)
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
- days,
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
- months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
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
- months,
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
- buckets = _aggregate_weekly(all_entries, weeks)
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
- overlay: list[tuple[float | None, float | None]] = []
5399
- for bucket in buckets:
5400
- sw = next(w for w in weeks if w.start_date.isoformat() == bucket.bucket)
5401
- ref = make_week_ref(
5402
- week_start_date=sw.start_date.isoformat(),
5403
- week_end_date=sw.end_date.isoformat(),
5404
- week_start_at=sw.start_ts,
5405
- week_end_at=sw.end_ts,
5406
- )
5407
- row = get_latest_usage_for_week(conn, ref, as_of_utc=as_of_utc)
5408
- # `weekly_usage_snapshots.weekly_percent` is NOT NULL per schema,
5409
- # but we still guard against missing rows (no snapshot captured
5410
- # yet for the week).
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
- buckets, overlay,
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
- sessions = _aggregate_claude_sessions(entries)
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
- sessions,
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: list[dict[str, Any]] = []
7894
- current_row: dict[str, Any] | None = None
7895
-
7896
- # Resolve oauth_usage cfg once -- it's loop-invariant across all
7897
- # trend rows. Falls back to defaults on config validation error.
7898
- try:
7899
- _fresh_cfg = _get_oauth_usage_config(config)
7900
- except OauthUsageConfigError:
7901
- _fresh_cfg = _get_oauth_usage_config({})
7902
-
7903
- # Build a set of week_start_date keys that occur MORE THAN ONCE
7904
- # in `weeks` — these are credited weeks (round-3, Bug B) where
7905
- # `_apply_reset_events_to_weekrefs` synthesized a pre-credit
7906
- # ref alongside the post-credit ref. Both refs share
7907
- # `week_start_date` so the default
7908
- # ``get_latest_usage_for_week(conn, ref)`` returns the SAME row
7909
- # (the most recent snapshot in the whole week) for both,
7910
- # collapsing the pre-credit row's `weeklyPercent` to the
7911
- # post-credit value. For those keys ONLY, pin `as_of_utc` to
7912
- # the ref's `week_end_at` so each segment renders its own
7913
- # latest snapshot. Non-credit weeks (single ref per key) keep
7914
- # the legacy unfiltered lookup so test fixtures that seed
7915
- # snapshots OUTSIDE the API-derived week window (e.g.
7916
- # `test_report_freshness`) keep finding their rows.
7917
- _split_keys = {
7918
- r.week_start.isoformat()
7919
- for r in weeks
7920
- if sum(
7921
- 1 for x in weeks
7922
- if x.week_start.isoformat() == r.week_start.isoformat()
7923
- ) > 1
7924
- }
7925
-
7926
- for week_ref in weeks:
7927
- key = week_ref.week_start.isoformat()
7928
- usage = get_latest_usage_for_week(
7929
- conn,
7930
- week_ref,
7931
- as_of_utc=(
7932
- week_ref.week_end_at if key in _split_keys else None
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
- # For weeks touched by a reset event, the cached
7937
- # weekly_cost_snapshots row covers the API-derived range (which
7938
- # extends past the reset into the NEW week or starts days
7939
- # BEFORE the reset in the new week). Bypass the cache and live-
7940
- # compute from session_entries over the effective range that
7941
- # _apply_reset_events_to_weekrefs baked into the ref. Normal
7942
- # weeks still hit the cache — zero perf change for the common
7943
- # path. `cost_captured_at` is pinned to the usage snapshot's
7944
- # captured time (not wall-clock `now_utc`): `_trend_row_recency_
7945
- # seconds` ranks the trend table by the freshest-data timestamp,
7946
- # and if every reset-affected week reported wall-clock now the
7947
- # oldest such week would sort like the newest and the current
7948
- # subscription week could slide below historical rows.
7949
- cost: sqlite3.Row | None = None
7950
- usage_captured_at = usage["captured_at_utc"] if usage else None
7951
- if _week_ref_has_reset_event(conn, week_ref):
7952
- cost_usd = _compute_cost_for_weekref(week_ref)
7953
- cost_captured_at = usage_captured_at if cost_usd is not None else None
7954
- range_start_iso = week_ref.week_start_at
7955
- range_end_iso = week_ref.week_end_at
7956
- else:
7957
- cost = get_latest_cost_for_week(conn, week_ref)
7958
- cost_usd = float(cost["cost_usd"]) if cost else None
7959
- cost_captured_at = cost["captured_at_utc"] if cost else None
7960
- range_start_iso = cost["range_start_iso"] if cost and cost["range_start_iso"] else None
7961
- range_end_iso = cost["range_end_iso"] if cost and cost["range_end_iso"] else None
7962
-
7963
- percent = float(usage["weekly_percent"]) if usage else None
7964
- ratio = (cost_usd / percent) if (cost_usd is not None and percent and percent > 0) else None
7965
- usage_captured_dt = _parse_iso_datetime_optional(usage_captured_at)
7966
- cost_captured_dt = _parse_iso_datetime_optional(cost_captured_at)
7967
-
7968
- as_of_dt: dt.datetime | None = None
7969
- if usage_captured_dt and cost_captured_dt:
7970
- as_of_dt = usage_captured_dt if usage_captured_dt >= cost_captured_dt else cost_captured_dt
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 usage_captured_at:
7990
- age_s = _seconds_since_iso(usage_captured_at)
7991
- if age_s is not None and age_s <= 86400:
7992
- row["freshness"] = {
7993
- "label": _freshness_label(age_s, _fresh_cfg),
7994
- "captured_at": usage_captured_at,
7995
- "age_seconds": int(age_s),
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
- # Bug D (v1.7.2 round-4): for credited weeks `weeks` contains
7999
- # TWO refs sharing `key` (pre-credit + post-credit segments).
8000
- # `current_ref` was routed through
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
- week_ref.key == current_ref.key
8010
- and week_ref.week_start_at == current_ref.week_start_at
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
- ordered_trend = list(reversed(trend))
8055
- if ordered_trend:
8056
- first_wsd = ordered_trend[0].get("weekStartDate")
8057
- last_wed = ordered_trend[-1].get("weekEndDate") or ordered_trend[-1].get("weekStartDate")
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
- ordered_trend,
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
- rows: list[dict[str, object]],
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
- `rows` is the in-memory `trend` list produced by `cmd_report` a list
12298
- of dicts keyed in the existing JSON-shape camelCase
12299
- (`weekStartDate`, `weeklyPercent`, `weeklyCostUSD`, `dollarsPerPercent`).
12300
- The plan's snake_case keys (`week_start_date`, `used_pct`, `cost_usd`,
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
- Caller MUST pass `rows` in chronological order (oldest first) so the
12305
- chart line trends left→right with time. `cmd_report`'s native `trend`
12306
- is newest-first; the gate site reverses before calling.
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 the
12309
- builder owns the canonical subtitle shape — no post-build re-stamp at
12310
- the gate site. The forward-reference return type matches the kernel's
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.get("weekStartDate")
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.get("weeklyPercent")
12340
- cost_raw = r.get("weeklyCostUSD")
12341
- dpp_raw = r.get("dollarsPerPercent")
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
- avg_dpp = (
12371
- sum(p.y_value for p in chart_pts) / len(chart_pts)
12372
- if chart_pts else 0.0
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
- rows: list["BucketUsage"],
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
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_daily`
12414
- inside `cmd_daily`. Each bucket has: `bucket` (YYYY-MM-DD date string),
12415
- `cost_usd`, `model_breakdowns` (list[dict] sorted by cost desc; first
12416
- entry is the top model).
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
- Caller MUST pass `rows` in chronological order so the BarChart bars
12430
- line up left-to-right with time. `_aggregate_daily` already returns
12431
- asc; the gate site re-sorts after `args.order == "desc"` was applied.
12432
-
12433
- `theme` and `reveal_projects` flow into the subtitle directly so the
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
- total_cost = 0.0
12448
- for r in rows:
12449
- total_cost += float(getattr(r, "cost_usd", 0.0) or 0.0)
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
- rows: list["BucketUsage"],
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
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_monthly`
12529
- inside `cmd_monthly`. Each bucket has: `bucket` (YYYY-MM string),
12530
- `cost_usd`, and `model_breakdowns` (list[dict] sorted by cost desc).
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
- Caller MUST pass `rows` in chronological order so the BarChart bars line
12544
- up left-to-right with time. `_aggregate_monthly` already returns asc;
12545
- the gate site fires before the `--order desc` reversal in `cmd_monthly`.
12546
-
12547
- `theme` and `reveal_projects` flow into the subtitle directly so the
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
- rows: list["BucketUsage"],
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
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_weekly`
12642
- inside `cmd_weekly`; each bucket carries `bucket` (week_start_date as
12643
- "YYYY-MM-DD"), `cost_usd`, `total_tokens`, and `model_breakdowns`
12644
- (list[dict] sorted by cost desc, each `{modelName, ..., cost}`).
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
- `overlay` is the parallel list of `(used_pct, dollars_per_pct)` tuples
12647
- computed by `cmd_weekly` from `weekly_usage_snapshots`. Either component
12648
- may be `None` for a week that has no captured snapshot yet — surfaces
12649
- in the snapshot row as a `0.0` PercentCell so the column stays aligned
12650
- (matching the table renderer's "no data 0%" behavior).
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
- sessions: list["ClaudeSessionUsage"],
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
- `sessions` is the in-memory `ClaudeSessionUsage` list produced by
13363
- `_aggregate_claude_sessions` inside `cmd_session`. Each session has:
13364
- `session_id` (UUID), `project_path` (filesystem path), `cost_usd`,
13365
- `last_activity` (`dt.datetime`), `models` (first-seen-order
13366
- `list[str]`), and the token aggregates.
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 `sessions` already sorted in the desired order;
13392
- the builder re-sorts internally by descending cost so the chart's
13393
- HBar bars rank consistently with the anonymization-mapping
13394
- (`_build_anon_mapping` also sorts by descending cost) keeping
13395
- `project-1` aligned with the highest-cost bar in the chart even
13396
- when the user asked for `--order asc`.
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
- sessions, key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0)
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.