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/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
- # Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
5226
- days = _aggregate_daily(all_entries, mode="auto", tz=tz)
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
- days,
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
- months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
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
- months,
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
- 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
- #
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
- 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))
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
- buckets, overlay,
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
- sessions = _aggregate_claude_sessions(entries)
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
- sessions,
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: 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
- ),
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
- # 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,
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 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
- }
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
- # 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).
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
- week_ref.key == current_ref.key
8010
- and week_ref.week_start_at == current_ref.week_start_at
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
- 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")
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
- ordered_trend,
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
- rows: list[dict[str, object]],
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
- `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.
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
- 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.
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 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.
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.get("weekStartDate")
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.get("weeklyPercent")
12340
- cost_raw = r.get("weeklyCostUSD")
12341
- dpp_raw = r.get("dollarsPerPercent")
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
- avg_dpp = (
12371
- sum(p.y_value for p in chart_pts) / len(chart_pts)
12372
- if chart_pts else 0.0
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
- rows: list["BucketUsage"],
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
- `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).
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
- 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.
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
- total_cost = 0.0
12448
- for r in rows:
12449
- total_cost += float(getattr(r, "cost_usd", 0.0) or 0.0)
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
- rows: list["BucketUsage"],
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
- `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).
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
- 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.
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
- rows: list["BucketUsage"],
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
- `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}`).
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
- `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).
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
- sessions: list["ClaudeSessionUsage"],
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
- `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.
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 `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`.
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
- sessions, key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0)
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.