cctally 1.8.1 → 1.8.2

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
@@ -359,6 +359,19 @@ TrendView = _lib_view_models.TrendView
359
359
  build_trend_view = _lib_view_models.build_trend_view
360
360
  SessionsView = _lib_view_models.SessionsView
361
361
  build_sessions_view = _lib_view_models.build_sessions_view
362
+ BlocksView = _lib_view_models.BlocksView
363
+ build_blocks_view = _lib_view_models.build_blocks_view
364
+ build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
365
+ ForecastView = _lib_view_models.ForecastView
366
+ build_forecast_view = _lib_view_models.build_forecast_view
367
+ CodexDailyView = _lib_view_models.CodexDailyView
368
+ build_codex_daily_view = _lib_view_models.build_codex_daily_view
369
+ CodexMonthlyView = _lib_view_models.CodexMonthlyView
370
+ build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
371
+ CodexWeeklyView = _lib_view_models.CodexWeeklyView
372
+ build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
373
+ CodexSessionView = _lib_view_models.CodexSessionView
374
+ build_codex_session_view = _lib_view_models.build_codex_session_view
362
375
 
363
376
  _lib_render = _load_sibling("_lib_render")
364
377
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
@@ -777,6 +790,7 @@ _dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_p
777
790
  _dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
778
791
  _build_block_detail = _cctally_dashboard._build_block_detail
779
792
  _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
793
+ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
780
794
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
781
795
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
782
796
  _iso_z = _cctally_dashboard._iso_z
@@ -5016,13 +5030,30 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5016
5030
  range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
5017
5031
  )
5018
5032
 
5019
- # Group into blocks
5020
- blocks = _group_entries_into_blocks(
5021
- all_entries, mode="auto",
5033
+ # Group into blocks via the view-model kernel (issue #56). The
5034
+ # heuristic-aware ``aggregated`` tuple holds the full Block list
5035
+ # (gaps included, oldest-first) — same shape the JSON / table
5036
+ # renderers expect. We materialize back to a list because
5037
+ # ``_maybe_swap_active_block_to_canonical`` mutates in-place.
5038
+ #
5039
+ # ``skip_rows=True`` (issue #60 review fix) opts out of the
5040
+ # dashboard-row construction inside ``build_blocks_view`` — the
5041
+ # per-block per-model enrichment that scans every entry per
5042
+ # non-gap block (O(B × N)). The CLI never reads ``view.rows``
5043
+ # (only ``view.aggregated`` here), so on large all-history blocks
5044
+ # runs we avoid quadratic-ish work we'd discard.
5045
+ view = build_blocks_view(
5046
+ all_entries,
5047
+ now_utc=now_utc,
5022
5048
  recorded_windows=recorded_windows,
5023
5049
  block_start_overrides=block_start_overrides,
5024
- now=now_utc,
5050
+ range_start=range_start,
5051
+ range_end=range_end,
5052
+ display_tz=tz,
5053
+ mode="auto",
5054
+ skip_rows=True,
5025
5055
  )
5056
+ blocks = list(view.aggregated)
5026
5057
 
5027
5058
  # Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
5028
5059
  # but a canonical ``five_hour_blocks`` row exists for the current 5h
@@ -5537,7 +5568,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5537
5568
  range_start, range_end = range
5538
5569
 
5539
5570
  entries = get_codex_entries(range_start, range_end)
5540
- days = _aggregate_codex_daily(entries, tz_name=tz_name)
5571
+ # Route through ``build_codex_daily_view`` (issue #58). The View
5572
+ # wraps ``_aggregate_codex_daily`` without changing it — preserves
5573
+ # LiteLLM token semantics, intentional dedup vs upstream, and
5574
+ # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
5575
+ view = build_codex_daily_view(
5576
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5577
+ )
5578
+ days = list(view.rows) # asc — matches aggregator default
5541
5579
  if args.order == "desc":
5542
5580
  days = list(reversed(days))
5543
5581
 
@@ -5559,7 +5597,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5559
5597
  y, m, d = bucket.split("-")
5560
5598
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5561
5599
 
5562
- tz_label = tz_name or _local_tz_name()
5600
+ tz_label = view.display_tz_label
5563
5601
  title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
5564
5602
  print(_render_codex_bucket_table(
5565
5603
  days,
@@ -5589,7 +5627,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5589
5627
  range_start, range_end = range
5590
5628
 
5591
5629
  entries = get_codex_entries(range_start, range_end)
5592
- months = _aggregate_codex_monthly(entries, tz_name=tz_name)
5630
+ # Route through ``build_codex_monthly_view`` (issue #58).
5631
+ view = build_codex_monthly_view(
5632
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5633
+ )
5634
+ months = list(view.rows)
5593
5635
  if args.order == "desc":
5594
5636
  months = list(reversed(months))
5595
5637
 
@@ -5611,7 +5653,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5611
5653
  y, m = bucket.split("-")
5612
5654
  return f"{_CODEX_MONTHS[int(m) - 1]}\n{y}"
5613
5655
 
5614
- tz_label = tz_name or _local_tz_name()
5656
+ tz_label = view.display_tz_label
5615
5657
  title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
5616
5658
  print(_render_codex_bucket_table(
5617
5659
  months,
@@ -5644,7 +5686,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5644
5686
  week_start_idx = WEEKDAY_MAP[week_start_name]
5645
5687
 
5646
5688
  entries = get_codex_entries(range_start, range_end)
5647
- weeks = _aggregate_codex_weekly(entries, tz_name, week_start_idx)
5689
+ # Route through ``build_codex_weekly_view`` (issue #58).
5690
+ view = build_codex_weekly_view(
5691
+ entries, now_utc=now_utc, tz_name=tz_name,
5692
+ week_start_idx=week_start_idx,
5693
+ )
5694
+ weeks = list(view.rows)
5648
5695
  if args.order == "desc":
5649
5696
  weeks = list(reversed(weeks))
5650
5697
 
@@ -5669,7 +5716,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5669
5716
  y, m, d = bucket.split("-")
5670
5717
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5671
5718
 
5672
- tz_label = tz_name or _local_tz_name()
5719
+ tz_label = view.display_tz_label
5673
5720
  title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
5674
5721
  print(_render_codex_bucket_table(
5675
5722
  weeks,
@@ -5699,8 +5746,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5699
5746
  range_start, range_end = range
5700
5747
 
5701
5748
  entries = get_codex_entries(range_start, range_end)
5702
- sessions = _aggregate_codex_sessions(entries)
5703
- # Aggregator returns descending by last_activity; --order asc reverses.
5749
+ # Route through ``build_codex_session_view`` (issue #58). View rows
5750
+ # come descending by last_activity (aggregator default + upstream
5751
+ # parity); --order asc reverses.
5752
+ view = build_codex_session_view(
5753
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5754
+ )
5755
+ sessions = list(view.rows)
5704
5756
  if args.order == "asc":
5705
5757
  sessions = list(reversed(sessions))
5706
5758
 
@@ -5714,7 +5766,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5714
5766
  print(_codex_sessions_to_json(sessions))
5715
5767
  return 0
5716
5768
 
5717
- tz_label = tz_name or _local_tz_name()
5769
+ tz_label = view.display_tz_label
5718
5770
  # Upstream uses "Sessions" (plural) in the session banner title.
5719
5771
  title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
5720
5772
  print(_render_codex_session_table(
@@ -8270,7 +8322,16 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8270
8322
  # sync_cache(conn) here — that prior call ran on the stats DB connection
8271
8323
  # (wrong conn) and was a no-op for the real cache anyway.
8272
8324
 
8273
- inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
8325
+ # Route through ``build_forecast_view`` (issue #57). The View is the
8326
+ # kernel-pattern wrapper; ``view.output`` carries the existing
8327
+ # ``ForecastOutput`` math result so every downstream renderer here
8328
+ # (text / JSON / status-line / share) reuses the same projection,
8329
+ # verdict, budgets, and per-method rate fields without recomputing.
8330
+ view = build_forecast_view(
8331
+ conn, now_utc=now_utc, targets=tuple(targets),
8332
+ skip_sync=args.no_sync, display_tz=args._resolved_tz,
8333
+ )
8334
+ inputs = view.output.inputs if view.output is not None else None
8274
8335
  if inputs is None:
8275
8336
  # No snapshot for the current week.
8276
8337
  if getattr(args, "format", None):
@@ -8336,12 +8397,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8336
8397
  print("forecast: no data for current week yet")
8337
8398
  return 0
8338
8399
 
8339
- output = _compute_forecast(inputs, targets)
8400
+ output = view.output
8340
8401
 
8341
8402
  # Shareable-reports gate: --format short-circuits the JSON / status-line /
8342
8403
  # terminal dispatch via `_share_render_and_emit`. The mutex in
8343
8404
  # `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
8344
- # `--status-line` from coexisting. The gate fires AFTER `_compute_forecast`
8405
+ # `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
8345
8406
  # so the snapshot reuses the same projection math as the terminal/JSON
8346
8407
  # paths — no parallel computation.
8347
8408
  if getattr(args, "format", None):
@@ -8886,10 +8947,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
8886
8947
  )
8887
8948
  else:
8888
8949
  period_end = _share_now_utc()
8889
- snap = _build_five_hour_blocks_snapshot(
8950
+ # Build a BlocksView from the API-anchored table rows
8951
+ # (issue #56). Reset-aware totals come from the table's
8952
+ # per-block columns (CLAUDE.md 5-hour gotcha block) so the
8953
+ # share snapshot's footer reads from the single typed
8954
+ # source rather than re-summing inline.
8955
+ view = build_blocks_view_from_table_rows(
8890
8956
  block_dicts,
8891
8957
  period_start=period_start,
8892
8958
  period_end=period_end,
8959
+ display_tz=args._resolved_tz,
8960
+ )
8961
+ snap = _build_five_hour_blocks_snapshot(
8962
+ view,
8963
+ period_start=period_start,
8964
+ period_end=period_end,
8893
8965
  display_tz=display_tz_str,
8894
8966
  version=_share_resolve_version(),
8895
8967
  theme=args.theme,
@@ -13211,7 +13283,7 @@ def _build_project_snapshot(
13211
13283
 
13212
13284
 
13213
13285
  def _build_five_hour_blocks_snapshot(
13214
- rows: list[dict],
13286
+ view: "BlocksView",
13215
13287
  *,
13216
13288
  period_start: dt.datetime,
13217
13289
  period_end: dt.datetime,
@@ -13223,13 +13295,18 @@ def _build_five_hour_blocks_snapshot(
13223
13295
  ) -> "ShareSnapshot":
13224
13296
  """Build a ShareSnapshot for `cctally five-hour-blocks`.
13225
13297
 
13226
- `rows` is the list of per-block dicts produced inside
13227
- `cmd_five_hour_blocks` (sqlite Row converted to dict, with the
13228
- `__is_active` side-channel attached). Schema fields used:
13229
- `block_start_at` (ISO timestamp), `total_cost_usd`,
13230
- `final_five_hour_percent`, `crossed_seven_day_reset` (0/1 int),
13231
- `seven_day_pct_at_block_start`, `seven_day_pct_at_block_end`, plus
13232
- the synthetic `__is_active` flag.
13298
+ `view` is the ``BlocksView`` produced by
13299
+ ``build_blocks_view_from_table_rows`` (issue #56). The
13300
+ API-anchored block dicts (sqlite Row → dict with the
13301
+ ``__is_active`` / ``__credits`` side-channels attached) live on
13302
+ ``view.aggregated``; reset-aware totals come from
13303
+ ``view.total_cost_usd`` so the share footer reads from the typed
13304
+ single source rather than re-summing inline. Schema fields used
13305
+ from each dict: ``block_start_at`` (ISO timestamp),
13306
+ ``total_cost_usd``, ``final_five_hour_percent``,
13307
+ ``crossed_seven_day_reset`` (0/1 int),
13308
+ ``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
13309
+ plus the synthetic ``__is_active`` flag.
13233
13310
 
13234
13311
  Deviations from the plan sketch (which assumed dict rows with keys
13235
13312
  `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
@@ -13261,11 +13338,12 @@ def _build_five_hour_blocks_snapshot(
13261
13338
  the builder owns the canonical subtitle shape — no post-build
13262
13339
  re-stamp at the gate site.
13263
13340
 
13264
- Caller MUST pass `rows` already in the desired chronological order
13265
- (cmd_five_hour_blocks pulls newest-first; we reverse here so the
13266
- BarChart bars line up oldest→newest left-to-right). Tabular row
13267
- order in the snapshot is irrelevant because the snapshot is what
13268
- gets rendered (the gate site short-circuits the table renderer).
13341
+ Caller MUST pass a view whose ``aggregated`` block dicts are
13342
+ already in the desired chronological order (cmd_five_hour_blocks
13343
+ pulls newest-first; we reverse here so the BarChart bars line up
13344
+ oldest→newest left-to-right). Tabular row order in the snapshot is
13345
+ irrelevant because the snapshot is what gets rendered (the gate
13346
+ site short-circuits the table renderer).
13269
13347
  """
13270
13348
  _lib_share = _share_load_lib()
13271
13349
  columns = (
@@ -13278,9 +13356,11 @@ def _build_five_hour_blocks_snapshot(
13278
13356
  _lib_share.ColumnSpec(key="cross_reset", label="Reset",
13279
13357
  align="left"),
13280
13358
  )
13281
- # Reverse so BarChart x-axis runs oldest→newest (cmd_five_hour_blocks
13282
- # produces newest-first DESC); table-row order in the snapshot tracks
13283
- # chart order so consumer expectations align.
13359
+ # `view.aggregated` carries the newest-first DESC block dicts the
13360
+ # caller built from the SELECT. Reverse so BarChart x-axis runs
13361
+ # oldest→newest; table-row order tracks chart order so consumer
13362
+ # expectations align.
13363
+ rows = list(view.aggregated)
13284
13364
  chrono_rows = list(reversed(rows))
13285
13365
  snap_rows: list = []
13286
13366
  chart_pts: list = []
@@ -13336,7 +13416,11 @@ def _build_five_hour_blocks_snapshot(
13336
13416
  _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
13337
13417
  if chart_pts else None
13338
13418
  )
13339
- sum_cost = sum(p.y_value for p in chart_pts)
13419
+ # Reset-aware total comes from the BlocksView (issue #56); avg
13420
+ # divides by `chart_pts` count so the share footer "Sum" totalled
13421
+ # and the per-block `chart_pts` cost values share a single source-
13422
+ # of-truth at `view.total_cost_usd`.
13423
+ sum_cost = view.total_cost_usd
13340
13424
  avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
13341
13425
  crossed_count = sum(
13342
13426
  1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))