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.
@@ -251,121 +251,55 @@ def _cctally():
251
251
  return sys.modules["cctally"]
252
252
 
253
253
 
254
+ # === Honest imports from extracted homes ===================================
255
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
256
+ # import from _cctally_core; already-decentralized buckets (X = _lib_*,
257
+ # Y = _cctally_*) import from their natural home. These bypass the
258
+ # legacy shim pattern entirely.
259
+ from _cctally_core import (
260
+ eprint,
261
+ now_utc_iso,
262
+ parse_iso_datetime,
263
+ _now_utc,
264
+ _command_as_of,
265
+ open_db,
266
+ get_latest_usage_for_week,
267
+ make_week_ref,
268
+ _get_alerts_config,
269
+ _AlertsConfigError,
270
+ )
271
+ from _lib_display_tz import (
272
+ format_display_dt,
273
+ resolve_display_tz,
274
+ normalize_display_tz_value,
275
+ _compute_display_block,
276
+ )
277
+ from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
278
+ from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
279
+ from _lib_five_hour import _canonical_5h_window_key
280
+ from _lib_subscription_weeks import _compute_subscription_weeks
281
+ from _lib_blocks import _group_entries_into_blocks
282
+ from _cctally_config import save_config, _load_config_unlocked
283
+ from _cctally_db import _render_migration_error_banner
284
+ from _cctally_cache import get_entries
285
+
286
+
254
287
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
255
288
  # Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
256
289
  # time), so monkeypatches on cctally's namespace propagate into the moved
257
- # code unchanged. Mirrors the precedent established in
258
- # ``bin/_cctally_record.py`` (34 shims), ``bin/_cctally_cache.py``
259
- # (4 shims), ``bin/_cctally_db.py`` (4 shims), and
260
- # ``bin/_cctally_update.py`` (8 shims).
261
- def eprint(*args, **kwargs):
262
- return sys.modules["cctally"].eprint(*args, **kwargs)
263
-
264
-
265
- def now_utc_iso(*args, **kwargs):
266
- return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
267
-
268
-
269
- def parse_iso_datetime(*args, **kwargs):
270
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
271
-
272
-
273
- def _now_utc(*args, **kwargs):
274
- return sys.modules["cctally"]._now_utc(*args, **kwargs)
275
-
276
-
277
- def _command_as_of(*args, **kwargs):
278
- return sys.modules["cctally"]._command_as_of(*args, **kwargs)
279
-
280
-
290
+ # code unchanged. `load_config` and `get_claude_session_entries` STAY as
291
+ # shims even though their natural homes are decentralized (_cctally_config
292
+ # / _cctally_cache) tests monkeypatch them via `ns["X"]` (21 sites total,
293
+ # audited 2026-05-17); direct imports would silently bypass the patches.
294
+ # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
281
295
  def load_config(*args, **kwargs):
282
296
  return sys.modules["cctally"].load_config(*args, **kwargs)
283
297
 
284
298
 
285
- def save_config(*args, **kwargs):
286
- return sys.modules["cctally"].save_config(*args, **kwargs)
287
-
288
-
289
- def open_db(*args, **kwargs):
290
- return sys.modules["cctally"].open_db(*args, **kwargs)
291
-
292
-
293
- def get_entries(*args, **kwargs):
294
- return sys.modules["cctally"].get_entries(*args, **kwargs)
295
-
296
-
297
299
  def get_claude_session_entries(*args, **kwargs):
298
300
  return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
299
301
 
300
302
 
301
- def get_latest_usage_for_week(*args, **kwargs):
302
- return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
303
-
304
-
305
- def make_week_ref(*args, **kwargs):
306
- return sys.modules["cctally"].make_week_ref(*args, **kwargs)
307
-
308
-
309
- def format_display_dt(*args, **kwargs):
310
- return sys.modules["cctally"].format_display_dt(*args, **kwargs)
311
-
312
-
313
- def resolve_display_tz(*args, **kwargs):
314
- return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
315
-
316
-
317
- def normalize_display_tz_value(*args, **kwargs):
318
- return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
319
-
320
-
321
- def _compute_display_block(*args, **kwargs):
322
- return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
323
-
324
-
325
- def _render_migration_error_banner(*args, **kwargs):
326
- return sys.modules["cctally"]._render_migration_error_banner(*args, **kwargs)
327
-
328
-
329
- def _aggregate_daily(*args, **kwargs):
330
- return sys.modules["cctally"]._aggregate_daily(*args, **kwargs)
331
-
332
-
333
- def _aggregate_monthly(*args, **kwargs):
334
- return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
335
-
336
-
337
- def _aggregate_weekly(*args, **kwargs):
338
- return sys.modules["cctally"]._aggregate_weekly(*args, **kwargs)
339
-
340
-
341
- def _calculate_entry_cost(*args, **kwargs):
342
- return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
343
-
344
-
345
- def _canonical_5h_window_key(*args, **kwargs):
346
- return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
347
-
348
-
349
- def _chip_for_model(*args, **kwargs):
350
- return sys.modules["cctally"]._chip_for_model(*args, **kwargs)
351
-
352
-
353
- def _short_model_name(*args, **kwargs):
354
- return sys.modules["cctally"]._short_model_name(*args, **kwargs)
355
-
356
-
357
- def _compute_subscription_weeks(*args, **kwargs):
358
- return sys.modules["cctally"]._compute_subscription_weeks(*args, **kwargs)
359
-
360
-
361
- def _group_entries_into_blocks(*args, **kwargs):
362
- return sys.modules["cctally"]._group_entries_into_blocks(*args, **kwargs)
363
-
364
-
365
- def _get_alerts_config(*args, **kwargs):
366
- return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
367
-
368
-
369
303
  def _warn_alerts_bad_config_once(*args, **kwargs):
370
304
  return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
371
305
 
@@ -402,10 +336,6 @@ def doctor_gather_state(*args, **kwargs):
402
336
  return sys.modules["cctally"].doctor_gather_state(*args, **kwargs)
403
337
 
404
338
 
405
- def _load_config_unlocked(*args, **kwargs):
406
- return sys.modules["cctally"]._load_config_unlocked(*args, **kwargs)
407
-
408
-
409
339
  def _apply_display_tz_override(*args, **kwargs):
410
340
  return sys.modules["cctally"]._apply_display_tz_override(*args, **kwargs)
411
341
 
@@ -1789,10 +1719,14 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
1789
1719
  display_tz: "ZoneInfo | None" = None) -> "list[MonthlyPeriodRow]":
1790
1720
  """Latest n calendar months as MonthlyPeriodRow, newest-first.
1791
1721
 
1792
- Builds via `_aggregate_monthly` over the trailing window
1793
- [now_utc - n calendar months, now_utc]. Bucketing and `is_current`
1794
- label both follow `display_tz` so users on a non-host display zone
1795
- see months grouped consistently with the rest of the UI.
1722
+ Thin wrapper around ``build_monthly_view`` (spec §6.2). The view
1723
+ owns the aggregator call, boundary-spillover drop, delta_cost_pct
1724
+ derivation, and ``is_current`` flagging this function just
1725
+ fetches the trailing window of entries and returns ``view.rows``.
1726
+
1727
+ The sync thread separately captures ``view.total_cost_usd`` /
1728
+ ``view.total_tokens`` for the envelope; see
1729
+ ``_tui_build_snapshot`` and ``DataSnapshot.monthly_total_*``.
1796
1730
  """
1797
1731
  # Compute window start = first day of the month that is (n - 1) months
1798
1732
  # before the current month. _aggregate_monthly walks all entries and
@@ -1811,44 +1745,10 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
1811
1745
  range_end = now_utc
1812
1746
 
1813
1747
  entries = get_entries(range_start, range_end, skip_sync=skip_sync)
1814
- buckets = _aggregate_monthly(entries, mode="auto", tz=display_tz)
1815
- # Reverse for newest-first AND cap to n BEFORE the delta loop. In tzs
1816
- # west of UTC, `range_start` (UTC midnight on the 1st) lands in the
1817
- # PREVIOUS local month, so entries in the boundary window get bucketed
1818
- # as a (n+1)th `'YYYY-MM'` row. Slicing here drops that partial bucket,
1819
- # which (a) keeps the visible history at the requested length and
1820
- # (b) makes the oldest visible row's delta `None` (prev = None) rather
1821
- # than a wildly wrong delta vs. a few-hour spillover bucket.
1822
- buckets = list(reversed(buckets))[:n]
1823
- rows: list[MonthlyPeriodRow] = []
1824
- # `_aggregate_monthly` keys buckets by `display_tz` (or local-tz when
1825
- # unset) month. Mirror that here so `is_current` matches even when
1826
- # now_utc straddles a tz month boundary (e.g. 23:30 UTC on the last
1827
- # of the month in a UTC+1 zone).
1828
- cur_label = (
1829
- now_utc.astimezone(display_tz) if display_tz is not None
1830
- # internal fallback: host-local intentional
1831
- else now_utc.astimezone()
1832
- ).strftime("%Y-%m")
1833
- for i, b in enumerate(buckets):
1834
- # b.bucket is the YYYY-MM string for monthly aggregation.
1835
- prev = buckets[i + 1] if i + 1 < len(buckets) else None
1836
- delta = None
1837
- if prev is not None and prev.cost_usd > 0:
1838
- delta = (b.cost_usd - prev.cost_usd) / prev.cost_usd
1839
- rows.append(MonthlyPeriodRow(
1840
- label=b.bucket,
1841
- cost_usd=b.cost_usd,
1842
- total_tokens=b.total_tokens,
1843
- input_tokens=b.input_tokens,
1844
- output_tokens=b.output_tokens,
1845
- cache_creation_tokens=b.cache_creation_tokens,
1846
- cache_read_tokens=b.cache_read_tokens,
1847
- delta_cost_pct=delta,
1848
- is_current=(b.bucket == cur_label),
1849
- models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
1850
- ))
1851
- return rows
1748
+ c = _cctally()
1749
+ view = c.build_monthly_view(entries, now_utc=now_utc, n=n,
1750
+ display_tz=display_tz)
1751
+ return list(view.rows)
1852
1752
 
1853
1753
 
1854
1754
  def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
@@ -1857,10 +1757,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1857
1757
  skip_sync: bool = False) -> "list[WeeklyPeriodRow]":
1858
1758
  """Latest n subscription weeks as WeeklyPeriodRow, newest-first.
1859
1759
 
1860
- Mirrors the bucket+overlay path used by `cmd_weekly`, scoped to a
1861
- trailing 84-day window (12 weeks plus slack). Boundaries come from
1862
- `_compute_subscription_weeks`; usage % comes from
1863
- `weekly_usage_snapshots` via `get_latest_usage_for_week`.
1760
+ Thin builder-using prelude + Bug-K pre-credit synthesis on top of
1761
+ ``view.rows``. ``build_weekly_view`` (in ``bin/_lib_view_models.py``)
1762
+ owns the bucket+overlay walk — this function calls it, swaps two
1763
+ presentation fields (``label`` ← ``display_start_date`` for post-
1764
+ early-reset weeks; ``is_current`` ← SubWeek-containing-now_utc with
1765
+ snapshot fallback so the "Now" pill tracks wall time), layers Bug-K
1766
+ pre-credit synthesized rows over the natural rows, then recomputes
1767
+ ``delta_cost_pct`` newest-first so the synthesized rows participate
1768
+ in the deltas.
1864
1769
 
1865
1770
  Note: weekly bucketing intentionally does NOT take ``display_tz`` —
1866
1771
  SubWeek bucket keys come from server-anchored stored anchors and the
@@ -1878,10 +1783,6 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1878
1783
  parse_iso_datetime(weeks[0].start_ts, "week_start_at"),
1879
1784
  )
1880
1785
  entries = get_entries(fetch_start, range_end, skip_sync=skip_sync)
1881
- buckets = _aggregate_weekly(entries, weeks)
1882
- if not buckets:
1883
- return []
1884
-
1885
1786
  as_of_utc = (
1886
1787
  range_end.astimezone(dt.timezone.utc)
1887
1788
  .replace(microsecond=0)
@@ -1889,6 +1790,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1889
1790
  .replace("+00:00", "Z")
1890
1791
  )
1891
1792
 
1793
+ # Builder prelude: produce the natural newest-first rows + overlay.
1794
+ c = _cctally()
1795
+ view = c.build_weekly_view(
1796
+ conn, entries, weeks=weeks, now_utc=now_utc,
1797
+ display_tz=None, as_of_utc=as_of_utc,
1798
+ )
1799
+ if not view.rows:
1800
+ return []
1801
+
1892
1802
  # Prefer the SubWeek that actually contains `now_utc` so the "Now" pill
1893
1803
  # tracks wall time even when `weekly_usage_snapshots` is stale (e.g.,
1894
1804
  # status line hasn't fired yet this week but cost entries already exist).
@@ -1913,47 +1823,36 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1913
1823
  else:
1914
1824
  cur_week_start = max(weeks, key=lambda w: w.start_date).start_date.isoformat()
1915
1825
 
1826
+ # SubWeek lookup by (start_ts, end_ts) — the builder identifies each row
1827
+ # by these ISO strings on ``WeeklyPeriodRow``, which match SubWeek 1:1
1828
+ # post _aggregate_weekly invariant.
1829
+ sw_by_window = {(w.start_ts, w.end_ts): w for w in weeks}
1830
+
1831
+ # Convert builder rows (newest-first) → oldest-first so the existing
1832
+ # Bug-K insertion logic (oldest-first indices) stays unchanged. Override
1833
+ # the two presentation fields that diverge between CLI/share (which use
1834
+ # ``start_date`` + "now in window" semantics) and the dashboard panel
1835
+ # (which uses ``display_start_date`` + "current SubWeek" semantics).
1916
1836
  rows_oldest_first: list[WeeklyPeriodRow] = []
1917
- for bucket in buckets:
1918
- sw = next(w for w in weeks if w.start_date.isoformat() == bucket.bucket)
1919
- # Label = MM-DD of the week's display_start_date — for non-reset
1920
- # weeks this equals start_date; for post-early-reset weeks the
1921
- # post-processor shifts it forward to the effective reset moment
1922
- # so the user sees the date the week actually began (04-23 vs the
1923
- # API-derived backdated 04-18).
1924
- label = sw.display_start_date.strftime("%m-%d")
1925
- ref = make_week_ref(
1926
- week_start_date=sw.start_date.isoformat(),
1927
- week_end_date=sw.end_date.isoformat(),
1928
- week_start_at=sw.start_ts,
1929
- week_end_at=sw.end_ts,
1930
- )
1931
- usage_row = get_latest_usage_for_week(conn, ref, as_of_utc=as_of_utc)
1932
- used_pct = None
1933
- dpp = None
1934
- if usage_row is not None and usage_row["weekly_percent"] is not None:
1935
- used_pct = float(usage_row["weekly_percent"])
1936
- dpp = (bucket.cost_usd / used_pct) if used_pct > 0 else None
1937
- rows_oldest_first.append(WeeklyPeriodRow(
1938
- label=label,
1939
- cost_usd=bucket.cost_usd,
1940
- total_tokens=bucket.total_tokens,
1941
- input_tokens=bucket.input_tokens,
1942
- output_tokens=bucket.output_tokens,
1943
- cache_creation_tokens=bucket.cache_creation_tokens,
1944
- cache_read_tokens=bucket.cache_read_tokens,
1945
- used_pct=used_pct,
1946
- dollar_per_pct=dpp,
1947
- delta_cost_pct=None, # filled below in newest-first pass
1837
+ for r in reversed(view.rows):
1838
+ sw = sw_by_window.get((r.week_start_at, r.week_end_at))
1839
+ if sw is not None:
1840
+ # Label = MM-DD of the week's display_start_date — for non-reset
1841
+ # weeks this equals start_date; for post-early-reset weeks the
1842
+ # post-processor shifts it forward to the effective reset moment
1843
+ # so the user sees the date the week actually began (04-23 vs the
1844
+ # API-derived backdated 04-18).
1845
+ r.label = sw.display_start_date.strftime("%m-%d")
1948
1846
  # is_current keys on start_date (the bucket / lookup key) on both
1949
1847
  # sides of the comparison; display_start_date may diverge for
1950
1848
  # reset-event weeks but that is intentional — display vs. lookup
1951
1849
  # are kept separate.
1952
- is_current=(sw.start_date.isoformat() == cur_week_start),
1953
- models=_model_breakdowns_to_models(bucket.model_breakdowns, bucket.cost_usd),
1954
- week_start_at=sw.start_ts,
1955
- week_end_at=sw.end_ts,
1956
- ))
1850
+ r.is_current = (sw.start_date.isoformat() == cur_week_start)
1851
+ # delta_cost_pct: builder computed it in asc order; reset and
1852
+ # recompute newest-first below AFTER Bug-K rows merge in, so the
1853
+ # synthesized rows participate in the deltas.
1854
+ r.delta_cost_pct = None
1855
+ rows_oldest_first.append(r)
1957
1856
 
1958
1857
  # Bug K (v1.7.2 round-5): synthesize a pre-credit segment row for
1959
1858
  # each in-place credit event. Without this the credited week shows
@@ -2320,27 +2219,51 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2320
2219
  display_tz: "ZoneInfo | None" = None) -> "list[DailyPanelRow]":
2321
2220
  """Latest n display-tz dates as DailyPanelRow, newest-first.
2322
2221
 
2323
- Mirrors `_dashboard_build_monthly_periods`: walks a wide trailing
2324
- window, runs `_aggregate_daily`, reverses to newest-first, caps to
2325
- `n`, then computes intensity buckets in-place. Bucketing and the
2326
- `is_today` reference both follow `display_tz` so users on a
2327
- non-host display zone see days grouped consistently with the rest
2328
- of the UI.
2222
+ Two-layer composition (spec §4.4, §6.1):
2223
+
2224
+ 1. ``build_daily_view`` (in ``bin/_lib_view_models.py``) is the
2225
+ data plane gap-free rows + totals derived from the
2226
+ aggregator output.
2227
+ 2. This function is the presentation adapter — materializes the
2228
+ contiguous N-day calendar window (gap days as zero-cost rows
2229
+ so the heatmap shows them as faded cells), fills the
2230
+ presentation-only ``label`` (``MM-DD``) and
2231
+ ``intensity_bucket`` (quintile via
2232
+ ``_compute_intensity_buckets``) fields.
2233
+
2234
+ Bucketing and the ``is_today`` reference both follow
2235
+ ``display_tz`` so users on a non-host display zone see days
2236
+ grouped consistently with the rest of the UI.
2237
+
2238
+ Sync-thread totals: the caller sums over the materialized rows
2239
+ this function returns and stashes the result on
2240
+ ``DataSnapshot.daily_total_cost_usd`` / ``daily_total_tokens`` for
2241
+ the envelope adapter. Sum-over-visible-rows preserves the
2242
+ structural-equality invariant the dashboard footer reads
2243
+ (`total === rows.reduce(...)`); gap days carry ``cost_usd=0.0`` /
2244
+ ``total_tokens=0`` so the sum stays gap-free semantically. Doing
2245
+ it at the sync-thread site keeps this function's return type
2246
+ stable (preserving the dashboard / TUI / test fixture monkeypatch
2247
+ surface that consumes a plain ``list[DailyPanelRow]``).
2329
2248
  """
2330
2249
  # Wide trailing window — n days of slack on either side keeps it
2331
2250
  # forgiving of tz boundary issues.
2332
2251
  range_start = now_utc - dt.timedelta(days=n + 1)
2333
2252
  range_end = now_utc
2334
2253
  entries = get_entries(range_start, range_end, skip_sync=skip_sync)
2335
- buckets = _aggregate_daily(entries, mode="auto", tz=display_tz)
2336
- if not buckets:
2254
+
2255
+ c = _cctally()
2256
+ view = c.build_daily_view(entries, now_utc=now_utc,
2257
+ display_tz=display_tz)
2258
+ if not view.rows:
2337
2259
  return []
2338
2260
 
2339
- # Materialize the full n-day calendar window so gap days render as
2340
- # zero-cost h0 cells (and today always appears, even on idle days).
2341
- # _aggregate_daily only emits buckets for dates with entries, so we
2342
- # overlay them onto a contiguous newest-first range.
2343
- buckets_by_date = {b.bucket: b for b in buckets}
2261
+ # Materialize the contiguous N-day window. ``view.rows`` is gap-free
2262
+ # (newest-first) and carries the data-plane fields; the adapter
2263
+ # overlays it onto the calendar window and fills the presentation-
2264
+ # only ``label`` / ``intensity_bucket`` (which the builder left at
2265
+ # dataclass defaults per spec §4.4).
2266
+ rows_by_date = {r.date: r for r in view.rows}
2344
2267
  today_local = (
2345
2268
  now_utc.astimezone(display_tz) if display_tz is not None
2346
2269
  # internal fallback: host-local intentional
@@ -2351,28 +2274,12 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2351
2274
  for i in range(n):
2352
2275
  d = today_local - dt.timedelta(days=i)
2353
2276
  date_str = d.isoformat()
2354
- b = buckets_by_date.get(date_str)
2355
- if b is not None:
2356
- # cache_read / (input + cache_creation + cache_read) — same ratio
2357
- # used by Block / Session / TuiSession dashboard surfaces. Do NOT
2358
- # switch to the diff-metrics formula (cache_read / (cache_read +
2359
- # input)) without aligning all dashboard surfaces.
2360
- denom = b.input_tokens + b.cache_creation_tokens + b.cache_read_tokens
2361
- cache_hit = (b.cache_read_tokens / denom * 100.0) if denom > 0 else None
2362
- rows.append(DailyPanelRow(
2363
- date=date_str,
2364
- label=date_str[5:], # YYYY-MM-DD → MM-DD
2365
- cost_usd=b.cost_usd,
2366
- is_today=(d == today_local),
2367
- intensity_bucket=0, # set by _compute_intensity_buckets below
2368
- models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
2369
- input_tokens=b.input_tokens,
2370
- output_tokens=b.output_tokens,
2371
- cache_creation_tokens=b.cache_creation_tokens,
2372
- cache_read_tokens=b.cache_read_tokens,
2373
- total_tokens=b.total_tokens,
2374
- cache_hit_pct=cache_hit,
2375
- ))
2277
+ existing = rows_by_date.get(date_str)
2278
+ if existing is not None:
2279
+ # Use the view-model row but fill the presentation-only
2280
+ # ``label`` (intensity_bucket is set by
2281
+ # ``_compute_intensity_buckets`` below).
2282
+ rows.append(dataclasses.replace(existing, label=date_str[5:]))
2376
2283
  else:
2377
2284
  # Zero-cost gap day: tokens default to 0, cache_hit_pct to None
2378
2285
  # (avoids /0 and signals 'no data' cleanly to the modal tile).
@@ -2947,8 +2854,28 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2947
2854
  # can distinguish "loading / not synced yet" (null parent) from
2948
2855
  # "synced + no data" (empty rows). For Weekly/Monthly, sync always
2949
2856
  # builds the rows list (even if empty), so we always emit the object.
2950
- weekly_env = {"rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods]}
2951
- monthly_env = {"rows": [_monthly_row_to_dict(r) for r in snap.monthly_periods]}
2857
+ weekly_env = {
2858
+ "rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods],
2859
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
2860
+ # totals. Sourced from sum-over-visible-rows in the sync thread
2861
+ # (``_tui_build_snapshot``) so the footer total is structurally
2862
+ # equal to the React panel's ``rows.reduce(...)``. The earlier
2863
+ # ``build_weekly_view``-sourced totals undercounted Bug-K
2864
+ # pre-credit synthesized rows on credit weeks; the regression is
2865
+ # captured by
2866
+ # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
2867
+ "total_cost_usd": snap.weekly_total_cost_usd,
2868
+ "total_tokens": snap.weekly_total_tokens,
2869
+ }
2870
+ monthly_env = {
2871
+ "rows": [_monthly_row_to_dict(r) for r in snap.monthly_periods],
2872
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
2873
+ # totals via sum-over-visible-rows so the React MonthlyPanel's
2874
+ # smoking-gun ``rows.reduce(...)`` collapses to a single envelope
2875
+ # read with the structural-equality invariant preserved.
2876
+ "total_cost_usd": snap.monthly_total_cost_usd,
2877
+ "total_tokens": snap.monthly_total_tokens,
2878
+ }
2952
2879
 
2953
2880
  blocks_env = {"rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel]}
2954
2881
 
@@ -2968,6 +2895,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2968
2895
  "rows": [_daily_row_to_dict(r) for r in daily_rows],
2969
2896
  "quantile_thresholds": daily_thresholds,
2970
2897
  "peak": daily_peak,
2898
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
2899
+ # totals via sum-over-visible-rows in the sync thread. Gap days
2900
+ # carry ``cost_usd=0.0`` and ``total_tokens=0`` so summing the
2901
+ # materialized panel rows preserves the gap-free semantics. The
2902
+ # React panel's `rows.reduce(...)` collapses to a single
2903
+ # envelope read with the structural-equality invariant
2904
+ # preserved.
2905
+ "total_cost_usd": snap.daily_total_cost_usd,
2906
+ "total_tokens": snap.daily_total_tokens,
2971
2907
  }
2972
2908
 
2973
2909
  # ---- threshold-actions T5: alerts envelope + settings mirror ----
@@ -2987,7 +2923,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2987
2923
  alerts_array = list(getattr(snap, "alerts", []) or [])
2988
2924
  try:
2989
2925
  _alerts_cfg = _get_alerts_config(load_config())
2990
- except sys.modules["cctally"]._AlertsConfigError as exc:
2926
+ except _AlertsConfigError as exc:
2991
2927
  _warn_alerts_bad_config_once(exc)
2992
2928
  _alerts_cfg = {
2993
2929
  "enabled": False,
@@ -3169,6 +3105,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
3169
3105
  }
3170
3106
  for w in (snap.weekly_history or [])
3171
3107
  ],
3108
+ # View-model unification (Bundle 1; spec §6.6): the
3109
+ # pre-computed 3-sample $/% mean. TrendPanel can stop
3110
+ # re-deriving the panel-average; TrendModal's median
3111
+ # over trend.history is out of scope for this refactor
3112
+ # (separate dataset, separate follow-up).
3113
+ "avg_dollars_per_pct": snap.trend_avg_dollars_per_pct,
3172
3114
  },
3173
3115
 
3174
3116
  "weekly": weekly_env,
@@ -3782,7 +3724,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
3782
3724
  # save_config has not yet been called).
3783
3725
  try:
3784
3726
  _get_alerts_config(merged)
3785
- except sys.modules["cctally"]._AlertsConfigError as exc:
3727
+ except _AlertsConfigError as exc:
3786
3728
  self._respond_json(400, {"error": str(exc)})
3787
3729
  return
3788
3730
 
@@ -71,35 +71,32 @@ def _cctally():
71
71
  return sys.modules["cctally"]
72
72
 
73
73
 
74
- # Module-level back-ref shims for the four callables most heavily used
75
- # across migration handlers + cmd_db_* renderers. Each shim resolves
76
- # `sys.modules['cctally'].X` at CALL TIME (not bind time), so
77
- # monkeypatches on cctally's namespace propagate into the moved code
78
- # unchanged. This lets the moved function bodies stay byte-identical
79
- # at every bare-name call site (`now_utc_iso(...)`,
80
- # `parse_iso_datetime(...)`, etc.) without requiring per-function
81
- # `c = _cctally()` boilerplate or `c.X` rewrites at every call site.
74
+ # === Honest imports from extracted homes ===================================
75
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
76
+ # import from _cctally_core. The legacy shim functions for these names
77
+ # are deleted.
78
+ from _cctally_core import (
79
+ eprint,
80
+ now_utc_iso,
81
+ parse_iso_datetime,
82
+ )
83
+
84
+
85
+ # Module-level back-ref shim for the one Z-high callable that STAYS in
86
+ # bin/cctally. Resolves `sys.modules['cctally'].X` at CALL TIME (not
87
+ # bind time), so monkeypatches on cctally's namespace propagate into the
88
+ # moved code unchanged. `_compute_block_totals` is Z-high (reaches into
89
+ # _cctally_cache via get_claude_session_entries) and is explicitly listed
90
+ # in spec §3.7's stays-on-shim allowlist.
82
91
  #
83
92
  # Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
84
93
  # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
85
94
  # accessed via the standard `c = _cctally()` + `c.X` pattern instead
86
95
  # (call-time lookup so fixture-HOME redirects propagate).
87
- def now_utc_iso(*args, **kwargs):
88
- return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
89
-
90
-
91
- def parse_iso_datetime(*args, **kwargs):
92
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
93
-
94
-
95
96
  def _compute_block_totals(*args, **kwargs):
96
97
  return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
97
98
 
98
99
 
99
- def eprint(*args, **kwargs):
100
- return sys.modules["cctally"].eprint(*args, **kwargs)
101
-
102
-
103
100
  # === BEGIN MOVED REGIONS ===
104
101
  # Regions below are inserted verbatim from bin/cctally. Bare-name
105
102
  # references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
@@ -1598,6 +1595,78 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
1598
1595
  raise
1599
1596
 
1600
1597
 
1598
+ @stats_migration("007_observed_pre_credit_pct")
1599
+ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
1600
+ """Add ``observed_pre_credit_pct`` to ``week_reset_events`` so the
1601
+ race-defensive cleanup DELETE in the in-place weekly credit branch
1602
+ has a durable record of the pre-credit baseline we observed at
1603
+ write time — independent of how the upstream claude-statusline
1604
+ tool rounds replays.
1605
+
1606
+ Today statusline replays cctally's ``hwm-7d`` value byte-identically,
1607
+ so the existing strict ``round(.,1)`` equality predicate is sound.
1608
+ Future-proofs against rounding drift: if Anthropic ever rounds the
1609
+ ``--percent`` payload differently from the OAuth API used by
1610
+ record-usage, or if statusline grows its own coarser rounding, a
1611
+ replay at e.g. 67.5 against a stored prior_pct = 67.4 would slip
1612
+ past strict equality and then dominate the reset-aware clamp's
1613
+ MAX over the post-credit segment. With the value stamped on the
1614
+ event row, the cleanup predicate widens to a 1.0pp tolerance band
1615
+ (issue #45) — wide enough to absorb single-digit drift, narrow
1616
+ enough that legitimate post-credit observations (≥25pp away by
1617
+ the in-place credit detection threshold's hypothesis) stay.
1618
+
1619
+ Backfill: NULL on existing rows. NULL is legacy / never-stamped;
1620
+ the live cleanup's bind still uses the current tick's in-scope
1621
+ ``prior_pct`` (the value we just observed and would have stamped),
1622
+ so the cleanup remains correct on the very tick that writes the
1623
+ row. The stored value matters for future tooling that may re-run
1624
+ cleanup against an already-written event row.
1625
+
1626
+ Companion live-path edits land in:
1627
+ - bin/cctally — CREATE TABLE adds the column for fresh installs.
1628
+ - bin/_cctally_record.py — in-place credit INSERT stamps
1629
+ ``observed_pre_credit_pct = prior_pct``; race-defensive DELETE
1630
+ switches from ``round(weekly_percent,1) = round(?,1)`` to
1631
+ ``ABS(weekly_percent - ?) < 1.0``.
1632
+
1633
+ Idempotent: a second invocation finds the column already present
1634
+ and returns. Empty-column fast path: when the live CREATE TABLE
1635
+ already carries the column (fresh install), stamp the marker and
1636
+ return without an ALTER. Simple ADD COLUMN — no UNIQUE constraint
1637
+ change, so no rename-recreate-copy needed (contrast migrations
1638
+ 005 / 006).
1639
+ """
1640
+ cols = {
1641
+ str(r[1])
1642
+ for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
1643
+ }
1644
+ if "observed_pre_credit_pct" in cols:
1645
+ conn.execute(
1646
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1647
+ "VALUES (?, ?)",
1648
+ ("007_observed_pre_credit_pct", now_utc_iso()),
1649
+ )
1650
+ conn.commit()
1651
+ return
1652
+
1653
+ conn.execute("BEGIN")
1654
+ try:
1655
+ conn.execute(
1656
+ "ALTER TABLE week_reset_events "
1657
+ "ADD COLUMN observed_pre_credit_pct REAL"
1658
+ )
1659
+ conn.execute(
1660
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1661
+ "VALUES (?, ?)",
1662
+ ("007_observed_pre_credit_pct", now_utc_iso()),
1663
+ )
1664
+ conn.commit()
1665
+ except Exception:
1666
+ conn.rollback()
1667
+ raise
1668
+
1669
+
1601
1670
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
1602
1671
 
1603
1672
  # ──────────────────────────────────────────────────────────────────────