cctally 1.8.0 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.8.2] - 2026-05-18
9
+
10
+ ### Added
11
+ - dashboard envelope: new `blocks.total_cost_usd` and `blocks.total_tokens` fields exposed alongside `blocks.rows[]` so `BlocksPanel` reads the footer total from the envelope instead of running `rows.reduce((acc, r) => acc + r.cost_usd, 0)` in JS. Additive — pre-#56 envelopes without the fields fall back to `?? 0` so first-paint stays stable. Sourced from the new `BlocksView.total_cost_usd` / `total_tokens` view-model fields populated by `build_blocks_view` at sync-thread time.
12
+
13
+ ### Changed
14
+ - view-model kernel: extend `bin/_lib_view_models.py` with `BlocksView` and two builders — `build_blocks_view(entries, …)` for the heuristic-aware path (`cmd_blocks` + dashboard Blocks panel) and `build_blocks_view_from_table_rows(rows, …)` for the API-anchored path (`cmd_five_hour_blocks` + share snapshot). Both return the same `BlocksView` dataclass (`rows: tuple[BlocksPanelRow, …]` + `aggregated: tuple` + totals + period metadata). `cmd_blocks`, `_dashboard_build_blocks_panel`, and `_build_five_hour_blocks_snapshot` now all route through the kernel; `BlocksPanelRow` moves from `bin/_cctally_tui.py` to `bin/_lib_view_models.py` alongside the other panel row dataclasses (re-exported from `_cctally_tui` for back-compat). Reset-aware totals invariant preserved (API-anchored path reads `five_hour_blocks.total_cost_usd` straight from the table — not recomputed from `session_entries`). Closes #56.
15
+ - view-model kernel: extend `bin/_lib_view_models.py` with `ForecastView` + `build_forecast_view(conn, *, now_utc, targets, skip_sync, display_tz)` wrapping the existing `_load_forecast_inputs` + `_compute_forecast` math kernel. The View carries the wrapped `ForecastOutput` plus surface fields the dashboard envelope adapter used to re-derive inline — per-method projections (`week_avg_projection_pct` / `recent_24h_projection_pct`), `verdict` (TUI design language) / `dashboard_verdict` (cap/capped/ok), `header_projection_pct` (the "pick pessimistic when verdict warns" routing so the header pct and verdict pill agree), the (100%, 90%) budget pair, and `low_confidence` / `low_confidence_reasons`. `cmd_forecast`, `_tui_build_forecast`, `snapshot_to_envelope`, and `_build_forecast_share_panel_data` now all route through the View instead of duplicating the projection / verdict / budget routing across three call sites; the legacy inline routing in `snapshot_to_envelope` is retained as a fallback for fixture snapshots that construct `DataSnapshot` positionally without populating `forecast_view`. Envelope contract unchanged — byte-stable across all 27 forecast harness scenarios + 18 dashboard scenarios + 1124 pytest tests. Closes #57.
16
+ - view-model kernel: extend `bin/_lib_view_models.py` with `CodexDailyView` / `CodexMonthlyView` / `CodexWeeklyView` / `CodexSessionView` + their four `build_codex_{daily,monthly,weekly,session}_view(entries, *, now_utc, tz_name, …)` builders, wrapping the existing `_aggregate_codex_{daily,monthly,weekly,sessions}` math kernel without changing it. The intentional Codex divergences from upstream (LiteLLM token semantics where `input_tokens` includes `cached_input_tokens` and `output_tokens` includes `reasoning_output_tokens`, the `info.total_token_usage.total_tokens` monotonic-advance dedup, `codex-session` descending-by-`last_activity` default, `--offline` no-op, and the `CODEX_LEGACY_FALLBACK_MODEL = "gpt-5"` one-shot stderr warning) are preserved end-to-end since the builders delegate to the same aggregator entrypoints. The Codex domain has no dashboard panel or share consumer, so each View carries `rows: tuple[CodexBucketUsage | CodexSessionUsage, …]` (the aggregator's typed output IS the surface) plus totals and `display_tz_label` — no parallel typed row dataclass is needed (`TrendView.rows` precedent). `cmd_codex_daily` / `cmd_codex_monthly` / `cmd_codex_weekly` / `cmd_codex_session` now route through the kernel; the `--order` reversal and per-mode no-data sentinel + JSON/table rendering paths stay in the CLI consumer. Byte-stable across all 27 codex harness scenarios (`codex-daily` 12/12, `codex-monthly` 4/4, `codex-weekly` 4/4, `codex-session` 7/7). Closes #58.
17
+ - view-model kernel: extend `TrendView` in `bin/_lib_view_models.py` with a pre-computed `median_dpp_non_current_4w: float | None` scalar — the dashboard Trend modal's "4-week median" hero KV used to derive this client-side via `median4NonCurrent` in `dashboard/web/src/modals/TrendModal.tsx`. The rule (drop the current row, keep finite dpp values, sort the last 4 ascending, take the midpoint `(s[1]+s[2])/2`, `None` when fewer than 4 valid non-current samples) now lives in `build_trend_view` so a future change ports across CLI / TUI / dashboard / share in one edit. The sync thread captures the 12-row history's `TrendView` (via new `_tui_build_weekly_history_view` sibling — same forecast-pattern wrap as `_tui_build_forecast_view`) and threads the scalar onto `DataSnapshot.trend_history_median_dpp`. `snapshot_to_envelope` adds an additive `trend.history_median_dpp` field on the envelope (additive contract — older snapshots that omit it fall back to the client-side `median4NonCurrentFallback` helper in TrendModal.tsx). All 18 dashboard harness scenarios regenerated to include the new field; rest of the suite (1004/0 + pytest PASS) unchanged. Closes #59.
18
+
19
+ ## [1.8.1] - 2026-05-18
20
+
21
+ ### Fixed
22
+ - `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).
23
+
8
24
  ## [1.8.0] - 2026-05-18
9
25
 
10
26
  ### Added
@@ -1350,13 +1350,16 @@ def _build_forecast_share_panel_data(options: dict,
1350
1350
  snap: "DataSnapshot | None") -> dict:
1351
1351
  """Forecast panel_data — projection + per-day budgets + days-to-ceiling.
1352
1352
 
1353
- Reuses `DataSnapshot.forecast` (ForecastOutput).
1354
- `projection_curve` is synthesized from `r_avg` / `r_recent` /
1355
- `inputs.p_now` the same arithmetic `snapshot_to_envelope` does for
1356
- `week_avg_projection_pct` / `recent_24h_projection_pct`, extended
1357
- across the next 7 days.
1353
+ Reuses ``DataSnapshot.forecast`` (ForecastOutput) and, when populated
1354
+ by the sync thread, ``DataSnapshot.forecast_view`` (the kernel
1355
+ wrapper from issue #57) for the (100, 90) budget pair.
1356
+ ``projection_curve`` is synthesized from ``r_avg`` / ``r_recent`` /
1357
+ ``inputs.p_now`` the same arithmetic ``snapshot_to_envelope`` does
1358
+ for ``week_avg_projection_pct`` / ``recent_24h_projection_pct``,
1359
+ extended across the next 7 days.
1358
1360
  """
1359
1361
  fc = getattr(snap, "forecast", None) if snap else None
1362
+ fc_view = getattr(snap, "forecast_view", None) if snap else None
1360
1363
  if fc is None:
1361
1364
  return {
1362
1365
  "projected_end_pct": 0.0,
@@ -1388,16 +1391,26 @@ def _build_forecast_share_panel_data(options: dict,
1388
1391
  return max(0.0, hours / 24.0)
1389
1392
  days_to_100 = _days_to_ceiling(100.0)
1390
1393
  days_to_90 = _days_to_ceiling(90.0)
1391
- # Daily budgets — pull from fc.budgets[] when present
1394
+ # Daily budgets — prefer ForecastView's pre-routed pair (issue #57)
1395
+ # when available; otherwise replay the legacy ``fc.budgets`` scan
1396
+ # inline so positionally-constructed fixture snapshots still work.
1392
1397
  budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
1393
1398
  "until_90pct": 0.0, "until_100pct": 0.0}
1394
- for b in getattr(fc, "budgets", None) or []:
1395
- tp = getattr(b, "target_percent", None)
1396
- dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
1397
- if tp == 100:
1398
- budgets["until_100pct"] = dpd
1399
- elif tp == 90:
1400
- budgets["until_90pct"] = dpd
1399
+ if fc_view is not None:
1400
+ budgets["until_100pct"] = float(
1401
+ fc_view.budget_100_per_day_usd or 0.0,
1402
+ )
1403
+ budgets["until_90pct"] = float(
1404
+ fc_view.budget_90_per_day_usd or 0.0,
1405
+ )
1406
+ else:
1407
+ for b in getattr(fc, "budgets", None) or []:
1408
+ tp = getattr(b, "target_percent", None)
1409
+ dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
1410
+ if tp == 100:
1411
+ budgets["until_100pct"] = dpd
1412
+ elif tp == 90:
1413
+ budgets["until_90pct"] = dpd
1401
1414
  # avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
1402
1415
  dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
1403
1416
  budgets["avg"] = dpp * r_avg * 24.0
@@ -2138,20 +2151,31 @@ def _build_block_detail(block: "Block",
2138
2151
  }
2139
2152
 
2140
2153
 
2141
- def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
2142
- now_utc: "dt.datetime",
2143
- *,
2144
- week_start_at: "dt.datetime",
2145
- week_end_at: "dt.datetime",
2146
- skip_sync: bool = False,
2147
- display_tz: "ZoneInfo | None" = None) -> "list[BlocksPanelRow]":
2148
- """Activity blocks (`is_gap=False`) inside ``[week_start_at, week_end_at)``,
2149
- newest-first.
2150
-
2151
- Mirrors the recorded-windows-widening trick used by ``cmd_blocks``: loads
2152
- recorded reset windows from ``[start - BLOCK_DURATION, end + BLOCK_DURATION]``
2153
- so a recorded reset just outside the visible window can still anchor
2154
- blocks inside it.
2154
+ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
2155
+ now_utc: "dt.datetime",
2156
+ *,
2157
+ week_start_at: "dt.datetime",
2158
+ week_end_at: "dt.datetime",
2159
+ skip_sync: bool = False,
2160
+ display_tz: "ZoneInfo | None" = None):
2161
+ """Build a ``BlocksView`` for the dashboard Blocks panel window
2162
+ ``[week_start_at, week_end_at)`` (issue #56).
2163
+
2164
+ Two-layer composition (mirrors `_dashboard_build_daily_panel`'s
2165
+ pattern):
2166
+
2167
+ 1. ``build_blocks_view`` (in ``bin/_lib_view_models.py``) is the
2168
+ data plane — `_group_entries_into_blocks` plus per-block model
2169
+ enrichment plus totals derivation.
2170
+ 2. This function is the presentation adapter — owns the
2171
+ recorded-windows-widening trick (loads reset windows from
2172
+ ``[start - BLOCK_DURATION, end + BLOCK_DURATION]`` so a recorded
2173
+ reset just outside the visible window can still anchor blocks
2174
+ inside it) and the strict-window entry filter.
2175
+
2176
+ Returning the full ``BlocksView`` (rows + totals) lets the sync
2177
+ thread populate ``DataSnapshot.blocks_total_cost_usd`` /
2178
+ ``blocks_total_tokens`` for the envelope without a second pass.
2155
2179
  """
2156
2180
  # Widen the entry window slightly so a recorded-reset window straddling
2157
2181
  # the boundary still picks up its entries.
@@ -2163,52 +2187,42 @@ def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
2163
2187
  recorded_windows, block_start_overrides = _load_recorded_five_hour_windows(
2164
2188
  fetch_start, fetch_end,
2165
2189
  )
2166
- blocks = _group_entries_into_blocks(
2167
- entries, mode="auto",
2190
+ c = _cctally()
2191
+ return c.build_blocks_view(
2192
+ entries,
2193
+ now_utc=now_utc,
2168
2194
  recorded_windows=recorded_windows,
2169
2195
  block_start_overrides=block_start_overrides,
2170
- now=now_utc,
2196
+ range_start=week_start_at,
2197
+ range_end=week_end_at,
2198
+ display_tz=display_tz,
2199
+ mode="auto",
2171
2200
  )
2172
- blocks = [b for b in blocks if not b.is_gap]
2173
- if not blocks:
2174
- return []
2175
2201
 
2176
- # Build per-block model-cost breakdown (matches _model_breakdowns_to_models
2177
- # input shape: dicts with `modelName` / `cost` keys, sorted desc by cost).
2178
- rows: list[BlocksPanelRow] = []
2179
- for b in blocks:
2180
- # Reaggregate the entries inside [b.start_time, b.end_time) for
2181
- # the model-split. _group_entries_into_blocks gives us total
2182
- # cost_usd per block but not per-model breakdown. Use
2183
- # `_calculate_entry_cost` (the single source-of-truth pricing
2184
- # path) so block per-model costs reconcile exactly with the
2185
- # block's own cost_usd.
2186
- per_model: dict[str, float] = {}
2187
- for e in entries:
2188
- if b.start_time <= e.timestamp < b.end_time:
2189
- cost = _calculate_entry_cost(
2190
- e.model, e.usage, mode="auto", cost_usd=e.cost_usd,
2191
- )
2192
- per_model[e.model] = per_model.get(e.model, 0.0) + cost
2193
- model_breakdowns = [
2194
- {"modelName": name, "cost": cost}
2195
- for name, cost in sorted(per_model.items(), key=lambda kv: -kv[1])
2196
- ]
2197
- local_label = format_display_dt(
2198
- b.start_time, display_tz, fmt="%H:%M %b %d", suffix=True,
2199
- )
2200
- rows.append(BlocksPanelRow(
2201
- start_at=b.start_time.astimezone(dt.timezone.utc).isoformat(),
2202
- end_at=b.end_time.astimezone(dt.timezone.utc).isoformat(),
2203
- anchor=b.anchor,
2204
- is_active=bool(b.is_active and b.entries_count > 0),
2205
- cost_usd=b.cost_usd,
2206
- models=_model_breakdowns_to_models(model_breakdowns, b.cost_usd),
2207
- label=local_label,
2208
- ))
2209
-
2210
- rows.sort(key=lambda r: r.start_at, reverse=True)
2211
- return rows
2202
+
2203
+ def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
2204
+ now_utc: "dt.datetime",
2205
+ *,
2206
+ week_start_at: "dt.datetime",
2207
+ week_end_at: "dt.datetime",
2208
+ skip_sync: bool = False,
2209
+ display_tz: "ZoneInfo | None" = None) -> "list[BlocksPanelRow]":
2210
+ """Activity blocks (`is_gap=False`) inside ``[week_start_at, week_end_at)``,
2211
+ newest-first.
2212
+
2213
+ Thin presentation shim over ``_dashboard_build_blocks_view`` —
2214
+ returns just ``view.rows`` so existing call sites (sync thread,
2215
+ share-data override resolver, monkeypatch surfaces) keep their
2216
+ ``list[BlocksPanelRow]`` contract.
2217
+ """
2218
+ view = _dashboard_build_blocks_view(
2219
+ conn, now_utc,
2220
+ week_start_at=week_start_at,
2221
+ week_end_at=week_end_at,
2222
+ skip_sync=skip_sync,
2223
+ display_tz=display_tz,
2224
+ )
2225
+ return list(view.rows)
2212
2226
 
2213
2227
 
2214
2228
  def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
@@ -2662,6 +2676,16 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2662
2676
  """
2663
2677
  cw = snap.current_week
2664
2678
  fc = snap.forecast
2679
+ # Issue #57 — prefer ``snap.forecast_view`` (precomputed by the
2680
+ # sync thread via ``build_forecast_view``) over re-deriving the
2681
+ # projection / verdict / header-routing / budget fields inline.
2682
+ # Falls back to the legacy inline routing below when
2683
+ # ``forecast_view`` is missing — fixture modules that construct
2684
+ # ``DataSnapshot`` positionally without the post-Bundle-1 fields
2685
+ # leave it at ``None``, and their goldens predate the View so
2686
+ # keeping the legacy path under that fallback preserves byte
2687
+ # stability.
2688
+ fc_view = getattr(snap, "forecast_view", None)
2665
2689
 
2666
2690
  # F1 fix: server-resolve the display tz to a CONCRETE IANA name and
2667
2691
  # surface it on the envelope so the browser never has to guess "local".
@@ -2690,19 +2714,35 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2690
2714
  five_hr = getattr(cw, "five_hour_pct", None) if cw is not None else None
2691
2715
  dollar_pp = getattr(cw, "dollars_per_percent", None) if cw is not None else None
2692
2716
 
2693
- # Forecast fields: route each projection by method identity from
2694
- # r_avg / r_recent and inputs.{p_now, remaining_hours}. Don't use
2695
- # final_percent_{low,high} directly those are numerical min/max of
2696
- # the two methods, which swaps the labels on decelerating weeks
2697
- # (r_recent < r_avg). Map defensively via getattr the JS only
2698
- # needs the values, not the internal structure.
2717
+ # Forecast field routing (issue #57). ``snap.forecast_view`` is the
2718
+ # single source of truth: ``build_forecast_view`` runs the per-
2719
+ # method projection / verdict / budget routing once and stashes
2720
+ # the surface fields on the View. The legacy inline derivation
2721
+ # remains as a fallback for fixture modules that construct
2722
+ # ``DataSnapshot`` positionally without populating ``forecast_view``
2723
+ # — their goldens predate the View, and the legacy block emits
2724
+ # the same numbers so byte stability is preserved.
2699
2725
  fcast_pct: "float | None" = None
2700
2726
  recent_24h_pct: "float | None" = None
2701
2727
  verdict: "str | None" = None
2702
2728
  confidence: "str | None" = None
2703
2729
  budget_100: "float | None" = None
2704
2730
  budget_90: "float | None" = None
2705
- if fc is not None:
2731
+ if fc_view is not None:
2732
+ fcast_pct = fc_view.week_avg_projection_pct
2733
+ recent_24h_pct = fc_view.recent_24h_projection_pct
2734
+ # ForecastView.dashboard_verdict / .confidence default to
2735
+ # ``"ok"`` / ``"unknown"`` even when ``output is None``; only
2736
+ # surface non-None envelope values when there's an actual
2737
+ # ForecastOutput backing them so the existing
2738
+ # ``verdict / confidence is None when fc is None`` shape stays.
2739
+ verdict = fc_view.dashboard_verdict if fc is not None else None
2740
+ confidence = fc_view.confidence if fc is not None else None
2741
+ budget_100 = fc_view.budget_100_per_day_usd
2742
+ budget_90 = fc_view.budget_90_per_day_usd
2743
+ elif fc is not None:
2744
+ # Legacy inline routing — kept verbatim for positionally-
2745
+ # constructed fixture snapshots that don't carry ``forecast_view``.
2706
2746
  inputs = getattr(fc, "inputs", None)
2707
2747
  if inputs is not None:
2708
2748
  confidence = getattr(inputs, "confidence", None)
@@ -2715,11 +2755,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2715
2755
  if (p_now is not None and rem_hrs is not None
2716
2756
  and r_recent is not None):
2717
2757
  p_final_recent = p_now + r_recent * rem_hrs
2718
- # Only emit recent_24h when the two projections diverge — if
2719
- # r_recent equals r_avg the second method added no info.
2720
2758
  if fcast_pct is None or p_final_recent != fcast_pct:
2721
2759
  recent_24h_pct = p_final_recent
2722
- # Verdict — simple mapping: "cap" if projected_cap, else "ok".
2723
2760
  if getattr(fc, "already_capped", False):
2724
2761
  verdict = "capped"
2725
2762
  elif getattr(fc, "projected_cap", False):
@@ -2773,20 +2810,23 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2773
2810
  reset_at_utc = we
2774
2811
 
2775
2812
  # Header forecast_pct should match the projection that drove the
2776
- # verdict pill next to it. When the verdict warns (projected to
2777
- # cap or already capped) and the recent-24h projection is higher
2778
- # than the week-average path, surface the pessimistic value so
2779
- # the number and the pill tell the same story. The Forecast panel
2780
- # still exposes both `week_avg_projection_pct` and
2781
- # `recent_24h_projection_pct` unchanged.
2782
- header_fcast_pct = fcast_pct
2783
- if (
2784
- verdict in ("cap", "capped")
2785
- and recent_24h_pct is not None
2786
- and fcast_pct is not None
2787
- and recent_24h_pct > fcast_pct
2788
- ):
2789
- header_fcast_pct = recent_24h_pct
2813
+ # verdict pill next to it. The View (issue #57) carries the
2814
+ # already-routed ``header_projection_pct``; the fallback path
2815
+ # replays the legacy routing inline for fixture snapshots that
2816
+ # don't populate ``forecast_view``. The Forecast panel still
2817
+ # exposes both ``week_avg_projection_pct`` and
2818
+ # ``recent_24h_projection_pct`` unchanged.
2819
+ if fc_view is not None and fc is not None:
2820
+ header_fcast_pct = fc_view.header_projection_pct
2821
+ else:
2822
+ header_fcast_pct = fcast_pct
2823
+ if (
2824
+ verdict in ("cap", "capped")
2825
+ and recent_24h_pct is not None
2826
+ and fcast_pct is not None
2827
+ and recent_24h_pct > fcast_pct
2828
+ ):
2829
+ header_fcast_pct = recent_24h_pct
2790
2830
 
2791
2831
  # ---- weekly / monthly periods ---------------------------------
2792
2832
  def _weekly_row_to_dict(r: "WeeklyPeriodRow") -> dict:
@@ -2877,7 +2917,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2877
2917
  "total_tokens": snap.monthly_total_tokens,
2878
2918
  }
2879
2919
 
2880
- blocks_env = {"rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel]}
2920
+ blocks_env = {
2921
+ "rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel],
2922
+ # View-model unification follow-up (issue #56): additive scalars
2923
+ # so the React BlocksPanel can stop running `rows.reduce(...)`
2924
+ # in JS. Cost is summed-over-visible-rows in
2925
+ # `_dashboard_build_blocks_view` (same structural invariant as
2926
+ # daily/weekly/monthly footers); ``total_tokens`` is sourced
2927
+ # from the same view since ``BlocksPanelRow`` doesn't carry
2928
+ # token columns and we don't want to widen that shape.
2929
+ "total_cost_usd": snap.blocks_total_cost_usd,
2930
+ "total_tokens": snap.blocks_total_tokens,
2931
+ }
2881
2932
 
2882
2933
  # Re-run helper to derive thresholds; mutates rows[*].intensity_bucket
2883
2934
  # (no-op for builder-constructed rows since values match cost_usd).
@@ -3106,11 +3157,20 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
3106
3157
  for w in (snap.weekly_history or [])
3107
3158
  ],
3108
3159
  # 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).
3160
+ # pre-computed 3-sample $/% mean. TrendPanel reads this
3161
+ # instead of re-deriving the panel-average.
3113
3162
  "avg_dollars_per_pct": snap.trend_avg_dollars_per_pct,
3163
+ # Issue #59 follow-up: pre-computed 4-week-median of
3164
+ # non-current ``dollars_per_percent`` over the 12-row
3165
+ # history. TrendModal.tsx's ``median4NonCurrent`` helper
3166
+ # used to compute this client-side; pre-computing on
3167
+ # ``build_trend_view`` keeps the rule
3168
+ # (``sort(last 4 non-current dpps)``, midpoint
3169
+ # ``(s[1]+s[2])/2``) in one place. ``None`` when fewer
3170
+ # than 4 valid non-current samples — modal's client-side
3171
+ # fallback handles the ``null`` case.
3172
+ "history_median_dpp":
3173
+ getattr(snap, "trend_history_median_dpp", None),
3114
3174
  },
3115
3175
 
3116
3176
  "weekly": weekly_env,
@@ -329,6 +329,10 @@ def _dashboard_build_blocks_panel(*args, **kwargs):
329
329
  return sys.modules["cctally"]._dashboard_build_blocks_panel(*args, **kwargs)
330
330
 
331
331
 
332
+ def _dashboard_build_blocks_view(*args, **kwargs):
333
+ return sys.modules["cctally"]._dashboard_build_blocks_view(*args, **kwargs)
334
+
335
+
332
336
  def _dashboard_build_daily_panel(*args, **kwargs):
333
337
  return sys.modules["cctally"]._dashboard_build_daily_panel(*args, **kwargs)
334
338
 
@@ -822,27 +826,15 @@ from _lib_view_models import ( # noqa: E402
822
826
  )
823
827
 
824
828
 
825
- @dataclass
826
- class BlocksPanelRow:
827
- """One row of the dashboard's Blocks panel.
828
-
829
- Subset of the `Block` dataclass — drops token counts (panel is
830
- cost-driven; tokens belong to a future modal), drops `entries_count`
831
- / `is_gap` / `burn_rate` / `projection` (panel doesn't render them),
832
- and pre-formats `label` server-side for the local-tz "HH:MM MMM DD"
833
- display.
834
- """
835
- start_at: str # ISO-8601 UTC
836
- end_at: str # ISO-8601 UTC, start_at + 5h
837
- anchor: str # 'recorded' | 'heuristic'
838
- is_active: bool # now_utc < end_at AND entries_count > 0
839
- cost_usd: float
840
- models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
841
- label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
842
-
843
-
844
- # DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
845
- from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
829
+ # BlocksPanelRow + DailyPanelRow + TuiSessionRow moved to
830
+ # bin/_lib_view_models.py — re-exported here so historical
831
+ # ``from _cctally_tui import BlocksPanelRow`` (or ``ns["BlocksPanelRow"]``
832
+ # direct-dict reads in tests) keep resolving.
833
+ from _lib_view_models import ( # noqa: E402
834
+ BlocksPanelRow,
835
+ DailyPanelRow,
836
+ TuiSessionRow,
837
+ )
846
838
 
847
839
 
848
840
  @dataclass
@@ -1043,7 +1035,32 @@ class DataSnapshot:
1043
1035
  monthly_total_tokens: int = 0
1044
1036
  weekly_total_cost_usd: float = 0.0
1045
1037
  weekly_total_tokens: int = 0
1038
+ # Blocks domain (issue #56). ``BlocksPanelRow`` doesn't carry token
1039
+ # columns, so the cost total alone preserves the structural
1040
+ # ``total === sum(visible rows).cost_usd`` invariant; ``blocks_total_tokens``
1041
+ # is sourced from the same ``BlocksView`` build so both scalars
1042
+ # come from a single typed pass.
1043
+ blocks_total_cost_usd: float = 0.0
1044
+ blocks_total_tokens: int = 0
1046
1045
  trend_avg_dollars_per_pct: float | None = None
1046
+ # Trend modal median (issue #59). Sourced from
1047
+ # ``build_trend_view``'s ``median_dpp_non_current_4w`` field — the
1048
+ # last-4-non-current dpp median TrendModal.tsx used to compute
1049
+ # client-side. Populated by the sync thread off the 12-row history
1050
+ # build (NOT the 8-row panel build); the dashboard envelope adapter
1051
+ # emits this as ``trend.history_median_dpp``. ``None`` for fixture
1052
+ # modules that construct ``DataSnapshot`` positionally without
1053
+ # going through ``_tui_build_snapshot``; the React modal keeps a
1054
+ # client-side fallback for that case.
1055
+ trend_history_median_dpp: float | None = None
1056
+ # Forecast domain (issue #57). ``ForecastView`` wraps
1057
+ # ``ForecastOutput`` and surfaces the per-method projection /
1058
+ # verdict / header-routing / budget fields the dashboard envelope
1059
+ # adapter used to re-derive inline. Field is ``None`` for fixture
1060
+ # modules that construct ``DataSnapshot`` directly without going
1061
+ # through ``_tui_build_snapshot``; the envelope adapter falls
1062
+ # back to the legacy inline routing in that case.
1063
+ forecast_view: Any | None = None
1047
1064
 
1048
1065
  @classmethod
1049
1066
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1426,11 +1443,32 @@ def _tui_build_forecast(
1426
1443
  *,
1427
1444
  skip_sync: bool = False,
1428
1445
  ):
1429
- """Call into existing forecast internals. Returns a ForecastOutput or None."""
1430
- inputs = _load_forecast_inputs(conn, now_utc, skip_sync=skip_sync)
1431
- if inputs is None:
1432
- return None
1433
- return _compute_forecast(inputs, [100, 90])
1446
+ """Build the TUI/dashboard sync-thread forecast.
1447
+
1448
+ Issue #57: routes through ``build_forecast_view`` (the kernel-pattern
1449
+ wrapper) and unwraps to a ``ForecastOutput`` for backward-compat with
1450
+ every existing ``snap.forecast`` consumer (TUI panels, envelope
1451
+ adapter, share builder). Use ``_tui_build_forecast_view`` when the
1452
+ full view is needed (e.g. ``snap.forecast_view`` population).
1453
+ """
1454
+ view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
1455
+ return view.output if view is not None else None
1456
+
1457
+
1458
+ def _tui_build_forecast_view(
1459
+ conn: sqlite3.Connection,
1460
+ now_utc: dt.datetime,
1461
+ *,
1462
+ skip_sync: bool = False,
1463
+ ):
1464
+ """Build the ``ForecastView`` (issue #57). Returns ``None`` only on
1465
+ error in callers — the empty-state View is constructed by the
1466
+ builder itself with ``output=None`` + ``verdict="LOW CONF"``.
1467
+ """
1468
+ c = _cctally()
1469
+ return c.build_forecast_view(
1470
+ conn, now_utc=now_utc, targets=(100, 90), skip_sync=skip_sync,
1471
+ )
1434
1472
 
1435
1473
 
1436
1474
  def _tui_build_trend(
@@ -1470,9 +1508,46 @@ def _tui_build_weekly_history(
1470
1508
  than parameterising the call site keeps the snapshot fields
1471
1509
  semantically distinct (panel data vs. modal data) and avoids
1472
1510
  accidental cross-contamination.
1511
+
1512
+ Issue #59: list-returning shim kept for back-compat with the
1513
+ public re-export at ``bin/cctally:13871``. New callers (the sync
1514
+ thread populating ``snap.weekly_history`` + the modal-median
1515
+ scalar) should prefer ``_tui_build_weekly_history_view`` so they
1516
+ pick up the pre-computed ``median_dpp_non_current_4w`` scalar
1517
+ without re-deriving.
1518
+ """
1519
+ return list(
1520
+ _tui_build_weekly_history_view(
1521
+ conn, now_utc, skip_sync=skip_sync, count=count,
1522
+ display_tz=display_tz,
1523
+ ).rows
1524
+ )
1525
+
1526
+
1527
+ def _tui_build_weekly_history_view(
1528
+ conn: sqlite3.Connection,
1529
+ now_utc: dt.datetime,
1530
+ *,
1531
+ skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
1532
+ count: int = 12,
1533
+ display_tz: "ZoneInfo | None" = None,
1534
+ ):
1535
+ """Build the full ``TrendView`` for the dashboard Trend modal
1536
+ (issue #59).
1537
+
1538
+ Wraps ``build_trend_view`` with the 12-row default the modal
1539
+ consumes. The returned ``TrendView`` carries the
1540
+ ``median_dpp_non_current_4w`` pre-computed scalar so the sync
1541
+ thread can populate ``DataSnapshot.trend_history_median_dpp``
1542
+ without re-running the median derivation client-side. The 8-row
1543
+ panel call (``_tui_build_trend``) goes through the same
1544
+ ``build_trend_view`` kernel; both builds carry their own median
1545
+ field but only the 12-row build's value reaches the envelope
1546
+ (``trend.history_median_dpp``).
1473
1547
  """
1474
- return _tui_build_trend(
1475
- conn, now_utc, skip_sync=skip_sync, count=count, display_tz=display_tz,
1548
+ c = _cctally()
1549
+ return c.build_trend_view(
1550
+ conn, now_utc=now_utc, n=max(1, count), display_tz=display_tz,
1476
1551
  )
1477
1552
 
1478
1553
 
@@ -1651,8 +1726,14 @@ def _tui_build_snapshot(
1651
1726
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
1652
1727
  except Exception as exc:
1653
1728
  errors.append(f"current-week: {exc}")
1729
+ fc_view = None
1654
1730
  try:
1655
- fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
1731
+ # Issue #57: build the ForecastView once so we capture both
1732
+ # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
1733
+ # the many TUI panel consumers still read) and the surface
1734
+ # fields the envelope adapter used to re-derive inline.
1735
+ fc_view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
1736
+ fc = fc_view.output if fc_view is not None else None
1656
1737
  except Exception as exc:
1657
1738
  errors.append(f"forecast: {exc}")
1658
1739
  # Trend: source from build_trend_view so we capture the 3-sample
@@ -1685,10 +1766,19 @@ def _tui_build_snapshot(
1685
1766
  milestones = _tui_build_percent_milestones(conn)
1686
1767
  except Exception as exc:
1687
1768
  errors.append(f"milestones: {exc}")
1769
+ history: list = []
1770
+ history_median_dpp: "float | None" = None
1688
1771
  try:
1689
- history = _tui_build_weekly_history(
1772
+ # Issue #59: build the full TrendView so we capture the
1773
+ # pre-computed 4-week-median-non-current scalar alongside
1774
+ # the row list; the dashboard envelope adapter surfaces
1775
+ # the scalar as ``trend.history_median_dpp`` so
1776
+ # TrendModal.tsx stops re-deriving it client-side.
1777
+ history_view = _tui_build_weekly_history_view(
1690
1778
  conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
1691
1779
  )
1780
+ history = list(history_view.rows)
1781
+ history_median_dpp = history_view.median_dpp_non_current_4w
1692
1782
  except Exception as exc:
1693
1783
  errors.append(f"weekly-history: {exc}")
1694
1784
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
@@ -1742,15 +1832,25 @@ def _tui_build_snapshot(
1742
1832
  except Exception as exc:
1743
1833
  errors.append(f"monthly-periods: {exc}")
1744
1834
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
1835
+ # Issue #56: build the BlocksView once and read both rows
1836
+ # (presentation) and totals (envelope scalars) from the same
1837
+ # pass. ``_dashboard_build_blocks_view`` is the view-returning
1838
+ # counterpart to ``_dashboard_build_blocks_panel`` (which is
1839
+ # kept as a thin shim for monkeypatch surfaces).
1840
+ blocks_total_cost_usd = 0.0
1841
+ blocks_total_tokens = 0
1745
1842
  try:
1746
1843
  if cw is not None:
1747
- blocks_panel = _dashboard_build_blocks_panel(
1844
+ _blocks_view = _dashboard_build_blocks_view(
1748
1845
  conn, now_utc,
1749
1846
  week_start_at=cw.week_start_at,
1750
1847
  week_end_at=cw.week_end_at,
1751
1848
  skip_sync=skip_sync,
1752
1849
  display_tz=_build_display_tz,
1753
1850
  )
1851
+ blocks_panel = list(_blocks_view.rows)
1852
+ blocks_total_cost_usd = _blocks_view.total_cost_usd
1853
+ blocks_total_tokens = _blocks_view.total_tokens
1754
1854
  except Exception as exc:
1755
1855
  errors.append(f"blocks-panel: {exc}")
1756
1856
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
@@ -1818,7 +1918,11 @@ def _tui_build_snapshot(
1818
1918
  monthly_total_tokens=monthly_total_tokens,
1819
1919
  weekly_total_cost_usd=weekly_total_cost_usd,
1820
1920
  weekly_total_tokens=weekly_total_tokens,
1921
+ blocks_total_cost_usd=blocks_total_cost_usd,
1922
+ blocks_total_tokens=blocks_total_tokens,
1821
1923
  trend_avg_dollars_per_pct=trend_avg_dpp,
1924
+ trend_history_median_dpp=history_median_dpp,
1925
+ forecast_view=fc_view,
1822
1926
  )
1823
1927
  finally:
1824
1928
  conn.close()
@@ -2218,6 +2322,16 @@ class _TuiSyncThread:
2218
2322
  self._ref.set(snap)
2219
2323
  except Exception as exc:
2220
2324
  # Don't crash the thread on unexpected errors — surface in UI.
2325
+ # Carry every additive view-model scalar through verbatim so
2326
+ # the prior frame's panel rows and their envelope totals stay
2327
+ # consistent. Bundle 1 / #56 / #57 / #59 each added envelope
2328
+ # scalars the React panels now trust over a client-side
2329
+ # ``rows.reduce``; without preserving them here, a sync crash
2330
+ # leaves populated rows next to a ``$0.00`` footer (the
2331
+ # dataclass defaults kick in for any field not explicitly
2332
+ # passed). The structural-equality invariant
2333
+ # ``total === sum(visible rows).cost_usd`` must survive a
2334
+ # crash recovery, not just the happy path.
2221
2335
  prev = self._ref.get()
2222
2336
  self._ref.set(DataSnapshot(
2223
2337
  current_week=prev.current_week,
@@ -2233,6 +2347,17 @@ class _TuiSyncThread:
2233
2347
  monthly_periods=prev.monthly_periods,
2234
2348
  blocks_panel=prev.blocks_panel,
2235
2349
  daily_panel=prev.daily_panel,
2350
+ daily_total_cost_usd=prev.daily_total_cost_usd,
2351
+ daily_total_tokens=prev.daily_total_tokens,
2352
+ monthly_total_cost_usd=prev.monthly_total_cost_usd,
2353
+ monthly_total_tokens=prev.monthly_total_tokens,
2354
+ weekly_total_cost_usd=prev.weekly_total_cost_usd,
2355
+ weekly_total_tokens=prev.weekly_total_tokens,
2356
+ blocks_total_cost_usd=prev.blocks_total_cost_usd,
2357
+ blocks_total_tokens=prev.blocks_total_tokens,
2358
+ trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
2359
+ trend_history_median_dpp=prev.trend_history_median_dpp,
2360
+ forecast_view=prev.forecast_view,
2236
2361
  ))
2237
2362
  # Wait up to interval, or until forced.
2238
2363
  for _ in range(int(max(1, self._interval * 10))):