cctally 1.7.4 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.8.1] - 2026-05-18
9
+
10
+ ### Fixed
11
+ - `cctally five-hour-blocks` and `cctally five-hour-breakdown`: rows annotated with the `⚡` credit marker no longer push the table's right border one cell to the right of non-credit rows. `_boxed_table` was computing column widths via `len()` and padding via `str.ljust` / `str.rjust`, both of which count Unicode codepoints; `⚡` (U+26A1, `unicodedata.east_asian_width == "W"`) is one codepoint but renders two terminal cells, so the credit-prefixed Block Start cell (and the `⚡ CREDIT` divider row in the breakdown's Threshold column) under-padded by one cell and the right border drifted off-column on those rows only. New module-level `_display_width()` helper counts terminal cells (Wide/Fullwidth → 2, combining marks → 0, else 1) and `_boxed_table` now uses it for both width-max and padding. Byte-identical on the common case — any cell with no East Asian Wide / Fullwidth glyph renders unchanged, so existing pytest + cctally-test-all goldens stay green (1124 pytest + 1004 harness scenarios). Regressions: `tests/test_boxed_table_display_width.py` (⚡ in first column, ⚡ in inner column, ASCII no-op invariance).
12
+
13
+ ## [1.8.0] - 2026-05-18
14
+
15
+ ### Added
16
+ - dashboard envelope: new `daily.total_cost_usd` and `daily.total_tokens` fields exposed alongside the existing `daily.rows[]` so `DailyPanel` and downstream consumers can read the panel's total from the envelope instead of summing rows client-side. Backward-compatible additive change — consumers that ignore the new fields keep working. Sourced from the new `DailyView.total_cost_usd` / `DailyView.total_tokens` view-model fields populated by `build_daily_view` at envelope-construction time.
17
+
18
+ ### Changed
19
+ - view-model kernel: introduce `bin/_lib_view_models.py` as the single canonical builder for each report command's render dataset — adds `DailyView` + `build_daily_view`, `MonthlyView` + `build_monthly_view`, `WeeklyView` + `build_weekly_view`, `TrendView` + `build_trend_view`, and `SessionsView` + `build_sessions_view`. CLI consumers (`cmd_daily`, `cmd_monthly`, `cmd_weekly`, `cmd_report`, `cmd_session`), dashboard envelope builders (`_dashboard_build_*`), share-output snapshots (`_build_*_snapshot`), and TUI builders (`_tui_build_*`) now all route through the same kernel instead of re-aggregating from `iter_entries()` independently. Collapses the prior 3× re-totaling cost across CLI / dashboard / share for daily, monthly, weekly, and sessions; downstream renderers consume the dataclass directly and the camelCase dict workaround between `cmd_report` and the dashboard trend panel is removed. Refactor-only externally — behavior changes are limited to the additive envelope fields above and the credit-week footer fix below.
20
+ - Refine npm package description and README header for discoverability: front-load high-intent search terms ("Claude Code usage tracker", "dashboard", "Pro/Max subscription limits", "quota forecasts", "ccusage-compatible") while preserving the distinctive "cost-per-percent trend" hook. Swap five `package.json` keywords — drop generic noise (`usage`, `cost`, `tracker`, `llm`, `ai-tools`) and add high-intent terms (`claude-code-dashboard`, `claude-code-quota`, `claude-code-cost`, `ccusage-alternative`, `quota-tracking`). GitHub repo About and topics updated to match in lockstep. Description re-indexes on npm at next publish.
21
+
22
+ ### Fixed
23
+ - dashboard weekly panel: footer total now matches the synthesized rows on credit weeks. Pre-fix, the panel's row list included `_apply_midweek_reset_override`'s synthesized pre-credit + post-credit rows split at `effective_reset_at_utc`, but the footer total was read from `weekly_cost_snapshots.cost_usd` (the snapshotted total over the original whole week), so the displayed rows summed to a different number than the footer. Now the footer reads `WeeklyView.total_cost_usd`, which sums over the same synthesized rows the panel renders, so the two always match by construction.
24
+
8
25
  ## [1.7.4] - 2026-05-17
9
26
 
10
27
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <strong>Track Claude Code subscription usage as a weekly $-per-1% trend. Local web dashboard, terminal UI, forecasts, and threshold alerts.</strong>
9
+ <strong>Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.</strong>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -1719,10 +1719,14 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
1719
1719
  display_tz: "ZoneInfo | None" = None) -> "list[MonthlyPeriodRow]":
1720
1720
  """Latest n calendar months as MonthlyPeriodRow, newest-first.
1721
1721
 
1722
- Builds via `_aggregate_monthly` over the trailing window
1723
- [now_utc - n calendar months, now_utc]. Bucketing and `is_current`
1724
- label both follow `display_tz` so users on a non-host display zone
1725
- 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_*``.
1726
1730
  """
1727
1731
  # Compute window start = first day of the month that is (n - 1) months
1728
1732
  # before the current month. _aggregate_monthly walks all entries and
@@ -1741,44 +1745,10 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
1741
1745
  range_end = now_utc
1742
1746
 
1743
1747
  entries = get_entries(range_start, range_end, skip_sync=skip_sync)
1744
- buckets = _aggregate_monthly(entries, mode="auto", tz=display_tz)
1745
- # Reverse for newest-first AND cap to n BEFORE the delta loop. In tzs
1746
- # west of UTC, `range_start` (UTC midnight on the 1st) lands in the
1747
- # PREVIOUS local month, so entries in the boundary window get bucketed
1748
- # as a (n+1)th `'YYYY-MM'` row. Slicing here drops that partial bucket,
1749
- # which (a) keeps the visible history at the requested length and
1750
- # (b) makes the oldest visible row's delta `None` (prev = None) rather
1751
- # than a wildly wrong delta vs. a few-hour spillover bucket.
1752
- buckets = list(reversed(buckets))[:n]
1753
- rows: list[MonthlyPeriodRow] = []
1754
- # `_aggregate_monthly` keys buckets by `display_tz` (or local-tz when
1755
- # unset) month. Mirror that here so `is_current` matches even when
1756
- # now_utc straddles a tz month boundary (e.g. 23:30 UTC on the last
1757
- # of the month in a UTC+1 zone).
1758
- cur_label = (
1759
- now_utc.astimezone(display_tz) if display_tz is not None
1760
- # internal fallback: host-local intentional
1761
- else now_utc.astimezone()
1762
- ).strftime("%Y-%m")
1763
- for i, b in enumerate(buckets):
1764
- # b.bucket is the YYYY-MM string for monthly aggregation.
1765
- prev = buckets[i + 1] if i + 1 < len(buckets) else None
1766
- delta = None
1767
- if prev is not None and prev.cost_usd > 0:
1768
- delta = (b.cost_usd - prev.cost_usd) / prev.cost_usd
1769
- rows.append(MonthlyPeriodRow(
1770
- label=b.bucket,
1771
- cost_usd=b.cost_usd,
1772
- total_tokens=b.total_tokens,
1773
- input_tokens=b.input_tokens,
1774
- output_tokens=b.output_tokens,
1775
- cache_creation_tokens=b.cache_creation_tokens,
1776
- cache_read_tokens=b.cache_read_tokens,
1777
- delta_cost_pct=delta,
1778
- is_current=(b.bucket == cur_label),
1779
- models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
1780
- ))
1781
- 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)
1782
1752
 
1783
1753
 
1784
1754
  def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
@@ -1787,10 +1757,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1787
1757
  skip_sync: bool = False) -> "list[WeeklyPeriodRow]":
1788
1758
  """Latest n subscription weeks as WeeklyPeriodRow, newest-first.
1789
1759
 
1790
- Mirrors the bucket+overlay path used by `cmd_weekly`, scoped to a
1791
- trailing 84-day window (12 weeks plus slack). Boundaries come from
1792
- `_compute_subscription_weeks`; usage % comes from
1793
- `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.
1794
1769
 
1795
1770
  Note: weekly bucketing intentionally does NOT take ``display_tz`` —
1796
1771
  SubWeek bucket keys come from server-anchored stored anchors and the
@@ -1808,10 +1783,6 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1808
1783
  parse_iso_datetime(weeks[0].start_ts, "week_start_at"),
1809
1784
  )
1810
1785
  entries = get_entries(fetch_start, range_end, skip_sync=skip_sync)
1811
- buckets = _aggregate_weekly(entries, weeks)
1812
- if not buckets:
1813
- return []
1814
-
1815
1786
  as_of_utc = (
1816
1787
  range_end.astimezone(dt.timezone.utc)
1817
1788
  .replace(microsecond=0)
@@ -1819,6 +1790,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1819
1790
  .replace("+00:00", "Z")
1820
1791
  )
1821
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
+
1822
1802
  # Prefer the SubWeek that actually contains `now_utc` so the "Now" pill
1823
1803
  # tracks wall time even when `weekly_usage_snapshots` is stale (e.g.,
1824
1804
  # status line hasn't fired yet this week but cost entries already exist).
@@ -1843,47 +1823,36 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1843
1823
  else:
1844
1824
  cur_week_start = max(weeks, key=lambda w: w.start_date).start_date.isoformat()
1845
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).
1846
1836
  rows_oldest_first: list[WeeklyPeriodRow] = []
1847
- for bucket in buckets:
1848
- sw = next(w for w in weeks if w.start_date.isoformat() == bucket.bucket)
1849
- # Label = MM-DD of the week's display_start_date — for non-reset
1850
- # weeks this equals start_date; for post-early-reset weeks the
1851
- # post-processor shifts it forward to the effective reset moment
1852
- # so the user sees the date the week actually began (04-23 vs the
1853
- # API-derived backdated 04-18).
1854
- label = sw.display_start_date.strftime("%m-%d")
1855
- ref = make_week_ref(
1856
- week_start_date=sw.start_date.isoformat(),
1857
- week_end_date=sw.end_date.isoformat(),
1858
- week_start_at=sw.start_ts,
1859
- week_end_at=sw.end_ts,
1860
- )
1861
- usage_row = get_latest_usage_for_week(conn, ref, as_of_utc=as_of_utc)
1862
- used_pct = None
1863
- dpp = None
1864
- if usage_row is not None and usage_row["weekly_percent"] is not None:
1865
- used_pct = float(usage_row["weekly_percent"])
1866
- dpp = (bucket.cost_usd / used_pct) if used_pct > 0 else None
1867
- rows_oldest_first.append(WeeklyPeriodRow(
1868
- label=label,
1869
- cost_usd=bucket.cost_usd,
1870
- total_tokens=bucket.total_tokens,
1871
- input_tokens=bucket.input_tokens,
1872
- output_tokens=bucket.output_tokens,
1873
- cache_creation_tokens=bucket.cache_creation_tokens,
1874
- cache_read_tokens=bucket.cache_read_tokens,
1875
- used_pct=used_pct,
1876
- dollar_per_pct=dpp,
1877
- 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")
1878
1846
  # is_current keys on start_date (the bucket / lookup key) on both
1879
1847
  # sides of the comparison; display_start_date may diverge for
1880
1848
  # reset-event weeks but that is intentional — display vs. lookup
1881
1849
  # are kept separate.
1882
- is_current=(sw.start_date.isoformat() == cur_week_start),
1883
- models=_model_breakdowns_to_models(bucket.model_breakdowns, bucket.cost_usd),
1884
- week_start_at=sw.start_ts,
1885
- week_end_at=sw.end_ts,
1886
- ))
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)
1887
1856
 
1888
1857
  # Bug K (v1.7.2 round-5): synthesize a pre-credit segment row for
1889
1858
  # each in-place credit event. Without this the credited week shows
@@ -2250,27 +2219,51 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2250
2219
  display_tz: "ZoneInfo | None" = None) -> "list[DailyPanelRow]":
2251
2220
  """Latest n display-tz dates as DailyPanelRow, newest-first.
2252
2221
 
2253
- Mirrors `_dashboard_build_monthly_periods`: walks a wide trailing
2254
- window, runs `_aggregate_daily`, reverses to newest-first, caps to
2255
- `n`, then computes intensity buckets in-place. Bucketing and the
2256
- `is_today` reference both follow `display_tz` so users on a
2257
- non-host display zone see days grouped consistently with the rest
2258
- 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]``).
2259
2248
  """
2260
2249
  # Wide trailing window — n days of slack on either side keeps it
2261
2250
  # forgiving of tz boundary issues.
2262
2251
  range_start = now_utc - dt.timedelta(days=n + 1)
2263
2252
  range_end = now_utc
2264
2253
  entries = get_entries(range_start, range_end, skip_sync=skip_sync)
2265
- buckets = _aggregate_daily(entries, mode="auto", tz=display_tz)
2266
- 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:
2267
2259
  return []
2268
2260
 
2269
- # Materialize the full n-day calendar window so gap days render as
2270
- # zero-cost h0 cells (and today always appears, even on idle days).
2271
- # _aggregate_daily only emits buckets for dates with entries, so we
2272
- # overlay them onto a contiguous newest-first range.
2273
- 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}
2274
2267
  today_local = (
2275
2268
  now_utc.astimezone(display_tz) if display_tz is not None
2276
2269
  # internal fallback: host-local intentional
@@ -2281,28 +2274,12 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2281
2274
  for i in range(n):
2282
2275
  d = today_local - dt.timedelta(days=i)
2283
2276
  date_str = d.isoformat()
2284
- b = buckets_by_date.get(date_str)
2285
- if b is not None:
2286
- # cache_read / (input + cache_creation + cache_read) — same ratio
2287
- # used by Block / Session / TuiSession dashboard surfaces. Do NOT
2288
- # switch to the diff-metrics formula (cache_read / (cache_read +
2289
- # input)) without aligning all dashboard surfaces.
2290
- denom = b.input_tokens + b.cache_creation_tokens + b.cache_read_tokens
2291
- cache_hit = (b.cache_read_tokens / denom * 100.0) if denom > 0 else None
2292
- rows.append(DailyPanelRow(
2293
- date=date_str,
2294
- label=date_str[5:], # YYYY-MM-DD → MM-DD
2295
- cost_usd=b.cost_usd,
2296
- is_today=(d == today_local),
2297
- intensity_bucket=0, # set by _compute_intensity_buckets below
2298
- models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
2299
- input_tokens=b.input_tokens,
2300
- output_tokens=b.output_tokens,
2301
- cache_creation_tokens=b.cache_creation_tokens,
2302
- cache_read_tokens=b.cache_read_tokens,
2303
- total_tokens=b.total_tokens,
2304
- cache_hit_pct=cache_hit,
2305
- ))
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:]))
2306
2283
  else:
2307
2284
  # Zero-cost gap day: tokens default to 0, cache_hit_pct to None
2308
2285
  # (avoids /0 and signals 'no data' cleanly to the modal tile).
@@ -2877,8 +2854,28 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2877
2854
  # can distinguish "loading / not synced yet" (null parent) from
2878
2855
  # "synced + no data" (empty rows). For Weekly/Monthly, sync always
2879
2856
  # builds the rows list (even if empty), so we always emit the object.
2880
- weekly_env = {"rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods]}
2881
- 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
+ }
2882
2879
 
2883
2880
  blocks_env = {"rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel]}
2884
2881
 
@@ -2898,6 +2895,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2898
2895
  "rows": [_daily_row_to_dict(r) for r in daily_rows],
2899
2896
  "quantile_thresholds": daily_thresholds,
2900
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,
2901
2907
  }
2902
2908
 
2903
2909
  # ---- threshold-actions T5: alerts envelope + settings mirror ----
@@ -3099,6 +3105,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
3099
3105
  }
3100
3106
  for w in (snap.weekly_history or [])
3101
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,
3102
3114
  },
3103
3115
 
3104
3116
  "weekly": weekly_env,