cctally 1.8.1 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/_cctally_dashboard.py +158 -98
- package/bin/_cctally_tui.py +156 -31
- package/bin/_lib_view_models.py +784 -0
- package/bin/cctally +118 -34
- package/dashboard/static/assets/{index-CfXu9Fx_.js → index-cWE5HB8O.js} +2 -2
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ 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
|
+
|
|
8
19
|
## [1.8.1] - 2026-05-18
|
|
9
20
|
|
|
10
21
|
### Fixed
|
|
@@ -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
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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 —
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
"""
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
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
|
|
2694
|
-
#
|
|
2695
|
-
#
|
|
2696
|
-
# the
|
|
2697
|
-
#
|
|
2698
|
-
#
|
|
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
|
|
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.
|
|
2777
|
-
#
|
|
2778
|
-
#
|
|
2779
|
-
#
|
|
2780
|
-
#
|
|
2781
|
-
#
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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 = {
|
|
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
|
|
3110
|
-
# re-deriving the panel-average
|
|
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,
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
"""
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))):
|