cctally 1.7.3 → 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.
@@ -202,43 +202,40 @@ def _cctally():
202
202
  return sys.modules["cctally"]
203
203
 
204
204
 
205
+ # === Honest imports from extracted homes ===================================
206
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
207
+ from _cctally_core import (
208
+ eprint,
209
+ parse_iso_datetime,
210
+ _now_utc,
211
+ open_db,
212
+ get_latest_usage_for_week,
213
+ _canonicalize_optional_iso,
214
+ make_week_ref,
215
+ )
216
+ from _lib_display_tz import (
217
+ format_display_dt,
218
+ resolve_display_tz,
219
+ normalize_display_tz_value,
220
+ _compute_display_block,
221
+ )
222
+ from _lib_aggregators import _aggregate_monthly
223
+
224
+
205
225
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
206
226
  # Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
207
227
  # time), so monkeypatches on cctally's namespace propagate into the moved
208
- # code unchanged. Mirrors the precedent established in
209
- # ``bin/_cctally_record.py``, ``bin/_cctally_cache.py``,
210
- # ``bin/_cctally_db.py``, ``bin/_cctally_update.py``, and
211
- # ``bin/_cctally_dashboard.py``.
212
- def eprint(*args, **kwargs):
213
- return sys.modules["cctally"].eprint(*args, **kwargs)
214
-
215
-
216
- def parse_iso_datetime(*args, **kwargs):
217
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
218
-
219
-
220
- def _now_utc(*args, **kwargs):
221
- return sys.modules["cctally"]._now_utc(*args, **kwargs)
222
-
223
-
224
- def open_db(*args, **kwargs):
225
- return sys.modules["cctally"].open_db(*args, **kwargs)
226
-
227
-
228
+ # code unchanged. `load_config` and `get_claude_session_entries` STAY as
229
+ # shims even though their natural homes are decentralized (_cctally_config
230
+ # / _cctally_cache) — tests monkeypatch them via `ns["X"]` (21 sites total,
231
+ # audited 2026-05-17); direct imports would silently bypass the patches.
232
+ # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
228
233
  def load_config(*args, **kwargs):
229
234
  return sys.modules["cctally"].load_config(*args, **kwargs)
230
235
 
231
236
 
232
- def format_display_dt(*args, **kwargs):
233
- return sys.modules["cctally"].format_display_dt(*args, **kwargs)
234
-
235
-
236
- def resolve_display_tz(*args, **kwargs):
237
- return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
238
-
239
-
240
- def normalize_display_tz_value(*args, **kwargs):
241
- return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
237
+ def get_claude_session_entries(*args, **kwargs):
238
+ return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
242
239
 
243
240
 
244
241
  def _resolve_display_tz_obj(*args, **kwargs):
@@ -253,10 +250,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
253
250
  return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
254
251
 
255
252
 
256
- def _compute_display_block(*args, **kwargs):
257
- return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
258
-
259
-
260
253
  def _compute_forecast(*args, **kwargs):
261
254
  return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
262
255
 
@@ -297,18 +290,6 @@ def _aggregate_claude_sessions(*args, **kwargs):
297
290
  return sys.modules["cctally"]._aggregate_claude_sessions(*args, **kwargs)
298
291
 
299
292
 
300
- def _aggregate_monthly(*args, **kwargs):
301
- return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
302
-
303
-
304
- def get_claude_session_entries(*args, **kwargs):
305
- return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
306
-
307
-
308
- def get_latest_usage_for_week(*args, **kwargs):
309
- return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
310
-
311
-
312
293
  def get_latest_cost_for_week(*args, **kwargs):
313
294
  return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
314
295
 
@@ -317,10 +298,6 @@ def get_milestones_for_week(*args, **kwargs):
317
298
  return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
318
299
 
319
300
 
320
- def _canonicalize_optional_iso(*args, **kwargs):
321
- return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
322
-
323
-
324
301
  def get_recent_weeks(*args, **kwargs):
325
302
  return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
326
303
 
@@ -831,55 +808,18 @@ class TuiCurrentWeek:
831
808
  five_hour_block: dict | None = None
832
809
 
833
810
 
834
- @dataclass
835
- class TuiTrendRow:
836
- week_label: str # e.g. "Apr 14"
837
- week_start_at: dt.datetime
838
- used_pct: float | None # None when the week has a cost snapshot
839
- # but no usage snapshot (phantom weeks)
840
- dollars_per_percent: float | None
841
- delta_dpp: float | None # vs prior week
842
- spark_height: int # 1..8 normalized
843
- is_current: bool
844
-
845
-
846
- @dataclass
847
- class WeeklyPeriodRow:
848
- """One subscription-week row for the dashboard's Weekly panel/modal.
849
-
850
- `models` is a list of `{model, display, chip, cost_usd, cost_pct}`
851
- dicts sorted by `cost_usd` descending. Pre-bucketed in Python so
852
- the React layer never re-derives per-model coloring.
853
- """
854
- label: str # "04-23" — MM-DD of the week start
855
- cost_usd: float
856
- total_tokens: int
857
- input_tokens: int
858
- output_tokens: int
859
- cache_creation_tokens: int
860
- cache_read_tokens: int
861
- used_pct: float | None # from weekly_usage_snapshots overlay
862
- dollar_per_pct: float | None # cost / used_pct when used_pct > 0
863
- delta_cost_pct: float | None # (cost - prev_cost) / prev_cost
864
- is_current: bool
865
- models: list[dict[str, Any]]
866
- week_start_at: str # ISO-8601 with tz, from SubWeek.start_ts
867
- week_end_at: str # ISO-8601 with tz, from SubWeek.end_ts
868
-
869
-
870
- @dataclass
871
- class MonthlyPeriodRow:
872
- """One calendar-month row for the dashboard's Monthly panel/modal."""
873
- label: str # "YYYY-MM"
874
- cost_usd: float
875
- total_tokens: int
876
- input_tokens: int
877
- output_tokens: int
878
- cache_creation_tokens: int
879
- cache_read_tokens: int
880
- delta_cost_pct: float | None
881
- is_current: bool
882
- models: list[dict[str, Any]]
811
+ # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
812
+ # Re-exported here so `from _cctally_tui import TuiTrendRow` and
813
+ # `ns["TuiTrendRow"]` direct-dict reads in tests keep resolving. The
814
+ # **extended** TuiTrendRow (spec §4.1: +10 nullable fields) is imported
815
+ # from the same module; the new fields default to None so existing TUI
816
+ # / dashboard fixtures that construct TuiTrendRow positionally stay
817
+ # byte-stable.
818
+ from _lib_view_models import ( # noqa: E402
819
+ TuiTrendRow,
820
+ WeeklyPeriodRow,
821
+ MonthlyPeriodRow,
822
+ )
883
823
 
884
824
 
885
825
  @dataclass
@@ -901,43 +841,8 @@ class BlocksPanelRow:
901
841
  label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
902
842
 
903
843
 
904
- @dataclass
905
- class DailyPanelRow:
906
- """One row of the dashboard's Daily heatmap panel.
907
-
908
- `intensity_bucket` is the server-computed quintile bucket (0..5) —
909
- bucket 0 is reserved for zero-cost days; buckets 1..5 are quintiles
910
- over non-zero days.
911
-
912
- v2.3: Added per-day token rollup + `cache_hit_pct` so the Daily
913
- detail modal can surface the same fields the CLI's `daily` command
914
- shows. Defaults preserve compatibility with `_empty_dashboard_snapshot`
915
- and any pre-v2.3 fixture that omits the new fields.
916
- """
917
- date: str # local-tz YYYY-MM-DD
918
- label: str # "MM-DD" — pre-formatted, mirrors Weekly/Monthly idiom
919
- cost_usd: float
920
- is_today: bool
921
- intensity_bucket: int # 0..5
922
- models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
923
- # ---- v2.3 additions: Daily modal token + cache rollup ----
924
- input_tokens: int = 0
925
- output_tokens: int = 0
926
- cache_creation_tokens: int = 0
927
- cache_read_tokens: int = 0
928
- total_tokens: int = 0
929
- cache_hit_pct: float | None = None
930
-
931
-
932
- @dataclass
933
- class TuiSessionRow:
934
- started_at: dt.datetime
935
- duration_minutes: float
936
- model_primary: str # first model used in the session
937
- cost_usd: float
938
- cache_hit_pct: float | None
939
- project_label: str # basename of project_path
940
- session_id: str # full session UUID (v2: needed for session-detail modal)
844
+ # DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
845
+ from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
941
846
 
942
847
 
943
848
  @dataclass
@@ -1119,6 +1024,26 @@ class DataSnapshot:
1119
1024
  # at sync-thread time so ``snapshot_to_envelope`` stays a pure
1120
1025
  # renderer; empty list when no current 5h block is bound.
1121
1026
  five_hour_milestones: list[dict] = field(default_factory=list)
1027
+ # ---- view-model unification (Bundle 1): pre-computed totals ----
1028
+ # Populated by the sync thread as sum-over-visible-rows over the
1029
+ # panel rows ``_dashboard_build_{daily,monthly,weekly}_periods``
1030
+ # returned (see ``_tui_build_snapshot``); the dashboard envelope
1031
+ # adapter emits these as ``<domain>.total_cost_usd`` /
1032
+ # ``total_tokens`` so the React panels stop running
1033
+ # ``rows.reduce(...)`` in JS. Sum-over-visible-rows is a structural
1034
+ # invariant: ``total === sum(rows[*].cost_usd)`` by construction —
1035
+ # see ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
1036
+ # ``trend_avg_dollars_per_pct`` is sourced from ``build_trend_view``
1037
+ # (3-sample-rule mean per spec §4.3). Defaults preserve
1038
+ # compatibility with pre-Bundle-1 fixture modules that construct
1039
+ # ``DataSnapshot`` positionally. Spec §6.6.
1040
+ daily_total_cost_usd: float = 0.0
1041
+ daily_total_tokens: int = 0
1042
+ monthly_total_cost_usd: float = 0.0
1043
+ monthly_total_tokens: int = 0
1044
+ weekly_total_cost_usd: float = 0.0
1045
+ weekly_total_tokens: int = 0
1046
+ trend_avg_dollars_per_pct: float | None = None
1122
1047
 
1123
1048
  @classmethod
1124
1049
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1518,158 +1443,16 @@ def _tui_build_trend(
1518
1443
  ) -> list[TuiTrendRow]:
1519
1444
  """Build the last `count` trend rows, chronological (oldest first).
1520
1445
 
1521
- `cmd_report` inlines its row build rather than delegating to a helper,
1522
- so instead of refactoring the subcommand we call the same underlying
1523
- loaders (`get_recent_weeks` + `get_latest_usage_for_week` +
1524
- `get_latest_cost_for_week`) directly here. Output for the shared
1525
- columns (`week_start_at`, `used_pct`, `dollars_per_percent`) matches
1526
- `cmd_report` byte-for-byte — verified in the bundle regression diff.
1446
+ Bundle 1 / Task 10: wraps the unified ``build_trend_view`` kernel
1447
+ (spec §5.4) the loop body that used to live here moved into
1448
+ ``bin/_lib_view_models.build_trend_view``. The TUI snapshot module
1449
+ consumes the first 7 ``TuiTrendRow`` fields and ignores the 10
1450
+ extended fields (which exist for cmd_report's JSON contract).
1527
1451
  """
1528
- # `get_recent_weeks` returns WeekRef rows DESC by week_start_date,
1529
- # already routed through `_apply_reset_events_to_weekrefs` so credited
1530
- # weeks come back as TWO refs (pre-credit + post-credit) sharing the
1531
- # same `WeekRef.key`.
1532
- week_refs = get_recent_weeks(conn, max(1, count))
1533
-
1534
- # Figure out which week_ref corresponds to the current subscription week.
1535
- # Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
1536
- # latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
1537
- # so its `week_start_at` reflects the post-credit segment (or the
1538
- # original start for non-credit weeks), then disambiguate the
1539
- # synthesized pre-credit ref from the live post-credit ref via BOTH
1540
- # `key` AND `week_start_at`. Key-only equality marks both segments
1541
- # as current, which is why the dashboard's trend panel previously
1542
- # showed two adjacent rows both highlighted as "current" with
1543
- # identical 4.0% values on the user's live in-place credit data.
1544
- latest_usage = conn.execute(
1545
- "SELECT week_start_date, week_end_date "
1546
- "FROM weekly_usage_snapshots "
1547
- "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1548
- ).fetchone()
1549
- current_key: str | None = None
1550
- current_week_start_at: str | None = None
1551
- if latest_usage is not None and latest_usage["week_start_date"] is not None:
1552
- current_key = latest_usage["week_start_date"]
1553
- try:
1554
- c = _cctally()
1555
- canon_start, canon_end = c._get_canonical_boundary_for_date(
1556
- conn, latest_usage["week_start_date"]
1557
- )
1558
- current_ref = c.make_week_ref(
1559
- week_start_date=latest_usage["week_start_date"],
1560
- week_end_date=latest_usage["week_end_date"],
1561
- week_start_at=canon_start,
1562
- week_end_at=canon_end,
1563
- )
1564
- _adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
1565
- if _adjusted:
1566
- current_week_start_at = _adjusted[0].week_start_at
1567
- except (ValueError, sqlite3.DatabaseError, AttributeError):
1568
- current_week_start_at = None
1569
-
1570
- # Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
1571
- # chronological order.
1572
- chrono = list(reversed(week_refs))
1573
- # Split-key set (Bug D): credited weeks appear twice in `week_refs`
1574
- # with identical `WeekRef.key`. For those keys ONLY, pin
1575
- # `as_of_utc=week_ref.week_end_at` so each segment finds its own
1576
- # latest snapshot — without this both segments collapse to the
1577
- # post-credit snapshot's weekly_percent. Non-credit weeks (single
1578
- # ref per key) keep the legacy unfiltered lookup.
1579
- _split_keys = {
1580
- r.key
1581
- for r in week_refs
1582
- if sum(1 for x in week_refs if x.key == r.key) > 1
1583
- }
1584
- intermediate: list[tuple[Any, float | None, float | None]] = []
1585
- for week_ref in chrono:
1586
- usage = get_latest_usage_for_week(
1587
- conn,
1588
- week_ref,
1589
- as_of_utc=(
1590
- week_ref.week_end_at if week_ref.key in _split_keys else None
1591
- ),
1592
- )
1593
- # See cmd_report for why reset-affected weeks skip the cost cache
1594
- # and live-compute from session_entries over the effective range.
1595
- if _week_ref_has_reset_event(conn, week_ref):
1596
- cost_usd = _compute_cost_for_weekref(week_ref)
1597
- else:
1598
- cost = get_latest_cost_for_week(conn, week_ref)
1599
- cost_usd = float(cost["cost_usd"]) if cost else None
1600
- percent = float(usage["weekly_percent"]) if usage else None
1601
- ratio = (cost_usd / percent) if (
1602
- cost_usd is not None and percent and percent > 0
1603
- ) else None
1604
- intermediate.append((week_ref, percent, ratio))
1605
-
1606
- # Normalize dpp into spark heights 1..8 across the window.
1607
- dpps = [d for _, _, d in intermediate if d is not None]
1608
- if dpps:
1609
- lo, hi = min(dpps), max(dpps)
1610
- span = (hi - lo) or 1e-9
1611
- else:
1612
- lo, hi, span = 0.0, 1.0, 1e-9
1613
-
1614
- out: list[TuiTrendRow] = []
1615
- prev_dpp: float | None = None
1616
- for week_ref, percent, dpp in intermediate:
1617
- delta = (dpp - prev_dpp) if (dpp is not None and prev_dpp is not None) else None
1618
- spark = 1
1619
- if dpp is not None:
1620
- spark = int(round((dpp - lo) / span * 7)) + 1
1621
- spark = max(1, min(8, spark))
1622
- # WeekRef.week_start is a date; synthesize a UTC datetime so
1623
- # TuiTrendRow carries a timezone-aware instant (prefer the explicit
1624
- # week_start_at if present).
1625
- if week_ref.week_start_at:
1626
- week_start_dt = parse_iso_datetime(
1627
- week_ref.week_start_at, "week_start_at"
1628
- )
1629
- week_label = format_display_dt(
1630
- week_start_dt, display_tz, fmt="%b %d", suffix=False,
1631
- )
1632
- else:
1633
- week_start_dt = dt.datetime.combine(
1634
- week_ref.week_start, dt.time(0, 0), dt.timezone.utc
1635
- )
1636
- # No real boundary instant — format the calendar date directly so
1637
- # localizing midnight-UTC doesn't shift it to the prior day in
1638
- # zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
1639
- week_label = week_ref.week_start.strftime("%b %d")
1640
- # Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
1641
- # for credited weeks so the pre-credit synthesized ref doesn't
1642
- # also light up as "current" — both refs share `key`, only their
1643
- # `week_start_at` differs (post-credit = effective reset moment,
1644
- # pre-credit = original API-derived start). Non-credit weeks
1645
- # have only one ref per key so `week_start_at` matching is
1646
- # automatic. When `current_week_start_at` is None (no reset
1647
- # event for the current week, or the resolution above failed),
1648
- # falls back to legacy key-only matching.
1649
- is_cur = (
1650
- current_key is not None
1651
- and week_ref.key == current_key
1652
- and (
1653
- current_week_start_at is None
1654
- or week_ref.week_start_at == current_week_start_at
1655
- )
1656
- )
1657
- out.append(TuiTrendRow(
1658
- week_label=week_label,
1659
- week_start_at=week_start_dt,
1660
- # Preserve None when no usage snapshot exists for this week —
1661
- # matches `cmd_report`'s "n/a" rendering (9980) and avoids
1662
- # fabricating a 0.0% row for the phantom-week case (cost
1663
- # snapshot present, usage snapshot absent).
1664
- used_pct=float(percent) if percent is not None else None,
1665
- dollars_per_percent=dpp,
1666
- delta_dpp=delta,
1667
- spark_height=spark,
1668
- is_current=is_cur,
1669
- ))
1670
- if dpp is not None:
1671
- prev_dpp = dpp
1672
- return out
1452
+ c = _cctally()
1453
+ view = c.build_trend_view(conn, now_utc=now_utc, n=max(1, count),
1454
+ display_tz=display_tz)
1455
+ return list(view.rows)
1673
1456
 
1674
1457
 
1675
1458
  def _tui_build_weekly_history(
@@ -1708,6 +1491,14 @@ def _tui_build_sessions(
1708
1491
 
1709
1492
  When `skip_sync=True`, honors the parent's `--no-sync` intent: no
1710
1493
  ingest pass, just read whatever is already cached.
1494
+
1495
+ Bundle 2 / Task 15: wraps the unified ``build_sessions_view``
1496
+ kernel — the prior 40-line inline-derivation body now lives at
1497
+ ``bin/_lib_view_models.build_sessions_view``. The TUI keeps
1498
+ ownership of the bounded 365-day scan window (rationale below) and
1499
+ consumes ``view.rows`` (the typed ``TuiSessionRow`` tuple). The
1500
+ view's parallel ``view.aggregated`` is reserved for the CLI / share
1501
+ surfaces; the TUI doesn't need ``ClaudeSessionUsage`` fields.
1711
1502
  """
1712
1503
  # Bounded scan window — the sessions pane promises "last `limit`". A
1713
1504
  # 365-day scan covers virtually all users (even one-session-every-few-days
@@ -1716,23 +1507,11 @@ def _tui_build_sessions(
1716
1507
  # on every entry in the window before slicing.
1717
1508
  range_start = now_utc - dt.timedelta(days=365)
1718
1509
  entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
1719
- sessions = _aggregate_claude_sessions(entries) # last_activity desc
1720
- out: list[TuiSessionRow] = []
1721
- for s in sessions[:limit]:
1722
- duration_min = (s.last_activity - s.first_activity).total_seconds() / 60.0
1723
- total_read = s.cache_read_tokens
1724
- total_io = s.input_tokens + s.cache_creation_tokens + s.cache_read_tokens
1725
- cache_pct = (total_read / total_io * 100) if total_io > 0 else None
1726
- out.append(TuiSessionRow(
1727
- started_at=s.first_activity,
1728
- duration_minutes=duration_min,
1729
- model_primary=(s.models[0] if s.models else "—"),
1730
- cost_usd=s.cost_usd,
1731
- cache_hit_pct=cache_pct,
1732
- project_label=os.path.basename(s.project_path) or s.project_path,
1733
- session_id=s.session_id,
1734
- ))
1735
- return out
1510
+ c = _cctally()
1511
+ view = c.build_sessions_view(
1512
+ entries, now_utc=now_utc, limit=limit, display_tz=None,
1513
+ )
1514
+ return list(view.rows)
1736
1515
 
1737
1516
 
1738
1517
  @dataclass
@@ -1876,10 +1655,19 @@ def _tui_build_snapshot(
1876
1655
  fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
1877
1656
  except Exception as exc:
1878
1657
  errors.append(f"forecast: {exc}")
1658
+ # Trend: source from build_trend_view so we capture the 3-sample
1659
+ # avg_dollars_per_pct alongside the rows. The TUI build path
1660
+ # historically called _tui_build_trend (which now wraps the
1661
+ # builder); calling the builder directly here saves one
1662
+ # `_aggregate_*` round-trip.
1663
+ trend_avg_dpp = None
1879
1664
  try:
1880
- trend = _tui_build_trend(
1881
- conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
1665
+ c = _cctally()
1666
+ _trend_view = c.build_trend_view(
1667
+ conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
1882
1668
  )
1669
+ trend = list(_trend_view.rows)
1670
+ trend_avg_dpp = _trend_view.avg_dollars_per_pct
1883
1671
  except Exception as exc:
1884
1672
  errors.append(f"trend: {exc}")
1885
1673
  try:
@@ -1904,17 +1692,53 @@ def _tui_build_snapshot(
1904
1692
  except Exception as exc:
1905
1693
  errors.append(f"weekly-history: {exc}")
1906
1694
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
1695
+ # Sync-thread view-model totals (spec §6.6): sum directly over
1696
+ # the panel rows the dashboard ACTUALLY renders. The previous
1697
+ # implementation called ``build_weekly_view`` a second time to
1698
+ # capture totals, but that builder doesn't see the Bug-K
1699
+ # pre-credit synthesized rows that ``_dashboard_build_weekly_periods``
1700
+ # layers on top (``_apply_reset_events_to_subweeks`` shifts the
1701
+ # post-reset SubWeek's ``start_ts`` so the pre-credit interval
1702
+ # has no SubWeek for ``_aggregate_weekly`` to bucket). On credit
1703
+ # weeks the sync-thread total understated the rendered footer by
1704
+ # hundreds of dollars (~$372 in the v1.7.2 round-5 data).
1705
+ # Sum-over-visible-rows is a structural invariant — see
1706
+ # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
1707
+ weekly_total_cost_usd = 0.0
1708
+ weekly_total_tokens = 0
1907
1709
  try:
1908
1710
  weekly_periods = _dashboard_build_weekly_periods(
1909
1711
  conn, now_utc, n=12, skip_sync=skip_sync
1910
1712
  )
1713
+ # ``sum(..., 0.0)`` pins the type to ``float`` on empty rows so
1714
+ # the envelope stays byte-stable with the pre-fix ``0.0`` shape
1715
+ # (the dashboard fixture goldens assert exact JSON match).
1716
+ weekly_total_cost_usd = sum(
1717
+ (r.cost_usd for r in weekly_periods), 0.0,
1718
+ )
1719
+ weekly_total_tokens = sum(
1720
+ (r.total_tokens for r in weekly_periods), 0,
1721
+ )
1911
1722
  except Exception as exc:
1912
1723
  errors.append(f"weekly-periods: {exc}")
1724
+ # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
1725
+ # (same invariant as weekly above). Monthly has no Bug-K analogue,
1726
+ # but coupling the footer total to the panel-row source of truth
1727
+ # eliminates a parallel ``build_monthly_view`` pass that did the
1728
+ # same arithmetic with no behavioral upside.
1729
+ monthly_total_cost_usd = 0.0
1730
+ monthly_total_tokens = 0
1913
1731
  try:
1914
1732
  monthly_periods = _dashboard_build_monthly_periods(
1915
1733
  conn, now_utc, n=12, skip_sync=skip_sync,
1916
1734
  display_tz=_build_display_tz,
1917
1735
  )
1736
+ monthly_total_cost_usd = sum(
1737
+ (r.cost_usd for r in monthly_periods), 0.0,
1738
+ )
1739
+ monthly_total_tokens = sum(
1740
+ (r.total_tokens for r in monthly_periods), 0,
1741
+ )
1918
1742
  except Exception as exc:
1919
1743
  errors.append(f"monthly-periods: {exc}")
1920
1744
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
@@ -1929,11 +1753,25 @@ def _tui_build_snapshot(
1929
1753
  )
1930
1754
  except Exception as exc:
1931
1755
  errors.append(f"blocks-panel: {exc}")
1756
+ # Sync-thread view-model totals (Bundle 1 / spec §6.6):
1757
+ # sum-over-visible-rows (same invariant as weekly/monthly above).
1758
+ # Gap days in the materialized panel carry ``cost_usd=0.0`` /
1759
+ # ``total_tokens=0``, so summing the panel rows preserves the
1760
+ # gap-free totals semantically — and removes a duplicate
1761
+ # ``build_daily_view`` pass that did the same arithmetic.
1762
+ daily_total_cost_usd = 0.0
1763
+ daily_total_tokens = 0
1932
1764
  try:
1933
1765
  daily_panel = _dashboard_build_daily_panel(
1934
1766
  conn, now_utc, n=30, skip_sync=skip_sync,
1935
1767
  display_tz=_build_display_tz,
1936
1768
  )
1769
+ daily_total_cost_usd = sum(
1770
+ (r.cost_usd for r in daily_panel), 0.0,
1771
+ )
1772
+ daily_total_tokens = sum(
1773
+ (r.total_tokens for r in daily_panel), 0,
1774
+ )
1937
1775
  except Exception as exc:
1938
1776
  errors.append(f"daily-panel: {exc}")
1939
1777
  # ---- threshold-actions T5: alerts envelope array ----
@@ -1974,6 +1812,13 @@ def _tui_build_snapshot(
1974
1812
  daily_panel=daily_panel,
1975
1813
  alerts=alerts,
1976
1814
  five_hour_milestones=fh_milestones,
1815
+ daily_total_cost_usd=daily_total_cost_usd,
1816
+ daily_total_tokens=daily_total_tokens,
1817
+ monthly_total_cost_usd=monthly_total_cost_usd,
1818
+ monthly_total_tokens=monthly_total_tokens,
1819
+ weekly_total_cost_usd=weekly_total_cost_usd,
1820
+ weekly_total_tokens=weekly_total_tokens,
1821
+ trend_avg_dollars_per_pct=trend_avg_dpp,
1977
1822
  )
1978
1823
  finally:
1979
1824
  conn.close()
@@ -192,28 +192,23 @@ def _cctally():
192
192
  return sys.modules["cctally"]
193
193
 
194
194
 
195
+ # === Honest imports from extracted homes ===================================
196
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
197
+ from _cctally_core import eprint, _now_utc
198
+ from _cctally_config import save_config
199
+
200
+
195
201
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
196
202
  # Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
197
203
  # time), so monkeypatches on cctally's namespace propagate into the moved
198
- # code unchanged. Mirrors the precedent established in
199
- # ``bin/_cctally_record.py`` (34 shims), ``bin/_cctally_cache.py``
200
- # (4 shims), and ``bin/_cctally_db.py`` (4 shims).
201
- def eprint(*args, **kwargs):
202
- return sys.modules["cctally"].eprint(*args, **kwargs)
203
-
204
-
205
- def _now_utc(*args, **kwargs):
206
- return sys.modules["cctally"]._now_utc(*args, **kwargs)
207
-
208
-
204
+ # code unchanged. `load_config` STAYS as a shim even though its natural
205
+ # home is _cctally_config — tests monkeypatch it via `ns["load_config"]`
206
+ # (16 sites, audited 2026-05-17); direct import would silently bypass.
207
+ # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
209
208
  def load_config(*args, **kwargs):
210
209
  return sys.modules["cctally"].load_config(*args, **kwargs)
211
210
 
212
211
 
213
- def save_config(*args, **kwargs):
214
- return sys.modules["cctally"].save_config(*args, **kwargs)
215
-
216
-
217
212
  def _release_read_latest_release_version(*args, **kwargs):
218
213
  return sys.modules["cctally"]._release_read_latest_release_version(
219
214
  *args, **kwargs
@@ -2114,7 +2109,7 @@ def _should_show_update_banner(
2114
2109
  return False
2115
2110
  if not config.get("update", {}).get("check", {}).get("enabled", True):
2116
2111
  return False
2117
- available, _ = c._compute_effective_update_available(state, suppress, c._now_utc())
2112
+ available, _ = c._compute_effective_update_available(state, suppress, _now_utc())
2118
2113
  return available
2119
2114
 
2120
2115
 
@@ -79,6 +79,13 @@ _lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
79
79
  SubWeek = _lib_subscription_weeks.SubWeek
80
80
 
81
81
 
82
+ # === Honest imports from extracted homes ===================================
83
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
84
+ # import from _cctally_core. `CODEX_SESSIONS_DIR` (path constant) and
85
+ # `_decode_escaped_cwd` (out-of-scope) stay on the _cctally() accessor.
86
+ from _cctally_core import parse_iso_datetime
87
+
88
+
82
89
  @dataclass
83
90
  class BucketUsage:
84
91
  """Aggregated usage for one time bucket.
@@ -247,7 +254,6 @@ def _aggregate_weekly(
247
254
  # candidate week in O(log W) per entry rather than the linear
248
255
  # scan that previously ran ~130k x ~54 = 7M comparisons.
249
256
  import bisect
250
- parse_iso_datetime = _cctally().parse_iso_datetime
251
257
  parsed_bounds: list[tuple[dt.datetime, dt.datetime, str]] = []
252
258
  for w in weeks:
253
259
  start_dt = parse_iso_datetime(w.start_ts, "week.start_ts")