cctally 1.65.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -0,0 +1,1520 @@
1
+ """Dashboard envelope serialization (#279 S5 F2, wide scope).
2
+
3
+ Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
4
+ name below, so ``bin/cctally``'s re-exports and the direct
5
+ ``sys.modules["_cctally_dashboard"].X`` reaches (TUI accessor shims
6
+ ``bin/_cctally_tui.py:373-378``, the pytest sites) keep resolving unchanged
7
+ (spec §2 re-export continuity).
8
+
9
+ What lives here (spec §4): the ``DataSnapshot`` → JSON-envelope serializer
10
+ call-tree — ``snapshot_to_envelope`` + ``_session_detail_to_envelope`` +
11
+ ``_iso_z`` + ``_compute_intensity_buckets`` — plus the sync-thread-called
12
+ "envelope builders" satellites the dashboard docstring groups with it:
13
+ ``_select_current_block_for_envelope``, ``_model_breakdowns_to_models``,
14
+ the five ``_envelope_rows_*`` + ``_ENVELOPE_AXIS_MAPPERS`` +
15
+ ``_build_alerts_envelope_array``. The positional-``DataSnapshot``
16
+ ``getattr(snap, …, None)`` / legacy-fallback branches inside
17
+ ``snapshot_to_envelope`` are load-bearing (spec §1) — moved verbatim.
18
+
19
+ ``DataSnapshot`` / ``TuiSessionDetail`` / ``DailyPanelRow`` stay in
20
+ ``bin/cctally`` (the TUI vertical); this module only CONSUMES instances
21
+ (all references are in string annotations under ``from __future__ import
22
+ annotations``).
23
+
24
+ Cross-module reaches (spec §2 "fully-qualify cross-module refs"): the
25
+ cctally-forwarding accessor shims the moved code called by bare name
26
+ (``load_config``, ``_apply_display_tz_override``, ``_freshness_label``,
27
+ ``_build_forecast_json_payload``, ``_warn_alerts_bad_config_once``,
28
+ ``doctor_gather_state``, ``_load_update_state``, ``_load_update_suppress``,
29
+ and ``c = _cctally()``) are inlined to their ``sys.modules["cctally"].X``
30
+ call-time reach — identical behavior, and the ``ns["X"]`` cctally-namespace
31
+ patch surface is preserved (none is patched on the dashboard module object;
32
+ audited). ``_channel_env_fragment`` STAYS in the dashboard (grouped with the
33
+ port/startup wiring), so it is reached at call time via
34
+ ``sys.modules["_cctally_dashboard"]``. ``_cache_report_snapshot_to_dict`` is
35
+ an honest import from ``_cctally_dashboard_cache_report`` (single source,
36
+ spec §4 / gate P2-2 — no duplicated function, just the bootstrap helper).
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import bisect
41
+ import datetime as dt
42
+ import importlib.util as _ilu
43
+ import os
44
+ import sys
45
+ from zoneinfo import ZoneInfo
46
+
47
+ from _cctally_core import (
48
+ _AlertsConfigError,
49
+ _BudgetConfigError,
50
+ _budget_alerts_active,
51
+ _get_alerts_config,
52
+ _get_budget_config,
53
+ )
54
+ from _lib_display_tz import _compute_display_block, format_display_dt
55
+ from _lib_pricing import _chip_for_model, _short_model_name
56
+
57
+
58
+ def _ensure_sibling_loaded(name: str) -> None:
59
+ """Register a NON-eager-loaded ``_cctally_dashboard_*`` sibling in ``sys.modules``.
60
+
61
+ Bootstrap-only copy (spec §4 / gate P2-2 — the FUNCTION
62
+ ``_cache_report_snapshot_to_dict`` stays single-source in the
63
+ cache-report sibling; only this helper is duplicated). Mirrors
64
+ ``bin/_cctally_record.py:_ensure_sibling_loaded`` so the honest import
65
+ below resolves under the ``SourceFileLoader`` harness path too.
66
+ """
67
+ if name in sys.modules:
68
+ return
69
+ try:
70
+ __import__(name) # bin/ on sys.path: prod script / conftest / pytest
71
+ return
72
+ except ModuleNotFoundError:
73
+ pass
74
+ _p = os.path.join(os.path.dirname(__file__), f"{name}.py")
75
+ _spec = _ilu.spec_from_file_location(name, _p)
76
+ _mod = _ilu.module_from_spec(_spec)
77
+ sys.modules[name] = _mod
78
+ _spec.loader.exec_module(_mod)
79
+
80
+
81
+ # Deliberate envelope → cache-report load edge (single source; no cycle,
82
+ # cache-report never references envelope). Bootstrapped, then honest-imported.
83
+ _ensure_sibling_loaded("_cctally_dashboard_cache_report")
84
+ from _cctally_dashboard_cache_report import _cache_report_snapshot_to_dict
85
+
86
+ # #279 S6 W4: the canonical None-safe UTC-Z serializer (its own former local
87
+ # copy was already exactly this — collapse to the single definition).
88
+ _ensure_sibling_loaded("_lib_json_envelope")
89
+ from _lib_json_envelope import _iso_z
90
+
91
+
92
+ def _model_breakdowns_to_models(model_breakdowns: list[dict[str, Any]],
93
+ period_cost: float) -> list[dict[str, Any]]:
94
+ """Reshape a BucketUsage.model_breakdowns list (sorted desc by cost)
95
+ into the dashboard envelope's `models` shape: short display name,
96
+ chip key, and cost percentage of the period."""
97
+ out: list[dict[str, Any]] = []
98
+ for mb in model_breakdowns:
99
+ canonical = mb["modelName"]
100
+ cost = float(mb["cost"])
101
+ pct = (cost / period_cost * 100.0) if period_cost > 0 else 0.0
102
+ # Display: reuse the canonical short-name helper (strips "claude-"
103
+ # prefix and any trailing "-YYYYMMDD" date suffix via regex).
104
+ display = _short_model_name(canonical)
105
+ out.append({
106
+ "model": canonical,
107
+ "display": display,
108
+ "chip": _chip_for_model(canonical),
109
+ "cost_usd": cost,
110
+ "cost_pct": pct,
111
+ })
112
+ return out
113
+
114
+
115
+ def _compute_intensity_buckets(rows: "list[DailyPanelRow]") -> list[float]:
116
+ """Mutates rows in place to set `intensity_bucket`. Returns the
117
+ threshold cut points (length 5 when any non-zero days exist, [] otherwise).
118
+
119
+ Differs from spec §5.1 pseudocode: the spec saturates the largest of
120
+ 5 distinct values at bucket 4; this variant correctly reaches bucket
121
+ 5. See `test_compute_intensity_buckets_quintile_distribution`.
122
+
123
+ Bucket 0 → cost == 0 (always).
124
+ Buckets 1..5 → quintile over non-zero days only, so a string of
125
+ zero-cost days doesn't flatten the scale.
126
+
127
+ The five raw cut points are taken from the sorted non-zero costs at
128
+ relative positions 0/5, 1/5, 2/5, 3/5, 4/5 (treated as "lower bound
129
+ of bucket 1..5"). When fewer than five distinct non-zero values
130
+ exist (e.g. one non-zero day), duplicate cut points are kept in the
131
+ returned threshold list for telemetry, but bucket assignment uses a
132
+ deduplicated copy so a single distinct value collapses to bucket 1
133
+ rather than saturating at bucket 5.
134
+ """
135
+ nonzero = sorted(r.cost_usd for r in rows if r.cost_usd > 0)
136
+ if not nonzero:
137
+ for r in rows:
138
+ r.intensity_bucket = 0
139
+ return []
140
+ # Five cut points at relative quintiles of the sorted non-zero days.
141
+ # Using i in 0..4 (rather than 1..5) makes each threshold the LOWER
142
+ # bound of its bucket — so a value v lands in bucket `count of
143
+ # thresholds <= v`, which is exactly `bisect_right(thresholds, v)`.
144
+ thresholds = [
145
+ nonzero[min(int(i / 5 * len(nonzero)), len(nonzero) - 1)]
146
+ for i in (0, 1, 2, 3, 4)
147
+ ]
148
+ # Dedup for the bucket-assignment lookup: with one distinct non-zero
149
+ # value the raw list is [v]*5, and bisect_right would return 5 →
150
+ # bucket 5. Dedup → [v], bisect_right → 1 → bucket 1, matching the
151
+ # "degenerate data lands at the lowest active bucket" intent.
152
+ distinct = sorted(set(thresholds))
153
+ for r in rows:
154
+ if r.cost_usd == 0:
155
+ r.intensity_bucket = 0
156
+ else:
157
+ r.intensity_bucket = min(bisect.bisect_right(distinct, r.cost_usd), 5)
158
+ return thresholds
159
+
160
+
161
+ def _select_current_block_for_envelope(
162
+ conn: sqlite3.Connection,
163
+ *,
164
+ current_used_pct: "float | None",
165
+ now_utc: "dt.datetime",
166
+ ) -> "dict | None":
167
+ """Select the current 5h block for the dashboard's current_week envelope.
168
+
169
+ Selection rule (spec §4.1): pick the row from ``five_hour_blocks`` whose
170
+ ``five_hour_window_key`` matches the latest ``weekly_usage_snapshots``
171
+ row's ``five_hour_window_key``. Returns None when no row matches —
172
+ binding to the latest snapshot's key (rather than highest
173
+ ``block_start_at``) prevents the panel's "current 5h session" copy from
174
+ surfacing stale closed blocks.
175
+
176
+ The latest-snapshot lookup is filtered to ``captured_at_utc <=
177
+ now_utc`` so callers that pin the clock (CCTALLY_AS_OF / --as-of) get
178
+ a block consistent with the same pinned moment used for ``used_pct``.
179
+ Without this filter, a past ``now_utc`` would still pick the
180
+ absolute-newest snapshot's window and surface a future block's deltas
181
+ against past usage — mirrors ``_handle_get_session_detail``'s
182
+ ``snap.generated_at`` plumbing for the same reason.
183
+ ``captured_at_utc`` is stored canonical UTC-Z (see ``now_utc_iso``),
184
+ so a lexicographic ``<=`` compare is chronological.
185
+
186
+ Stale-block suppression: the block lookup additionally filters on
187
+ ``is_closed = 0`` AND ``five_hour_resets_at > now_utc``. The two
188
+ clauses are belt-and-suspenders — ``maybe_update_five_hour_block``'s
189
+ natural-expiration sweep (``UPDATE … SET is_closed = 1``) only fires
190
+ on a ``record-usage`` tick, but the dashboard read can race ahead of
191
+ the next tick when the user is idle past the reset. A block that
192
+ matches the latest snapshot's key but whose ``five_hour_resets_at``
193
+ has already passed is no longer current — surfacing it would render
194
+ a stale "current 5h session" delta against post-reset weekly usage.
195
+ Returns ``None`` in that case; the React panel falls back to the
196
+ legacy single-big-number layout (CLAUDE.md gotcha:
197
+ ``Dashboard current_week.five_hour_block …``).
198
+
199
+ Returned dict has snake_case keys to match the existing dashboard
200
+ envelope convention (``current_week`` is snake_case; CLI ``--json`` is
201
+ camelCase — separate conventions, see CLAUDE.md).
202
+
203
+ Delta semantics:
204
+ - Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
205
+ (the natural "how much 7d% has changed during this 5h block" read).
206
+ - Crossed block (``crossed_seven_day_reset == 1``): the block straddles
207
+ a weekly reset, so the natural delta would be dominated by the
208
+ reset itself (e.g. −94pp) rather than the user's actual burn-rate.
209
+ Compute the POST-RESET delta instead — ``current_used_pct -
210
+ weekly_percent_at_first_post_reset_snapshot_in_block``. The React
211
+ panel prefixes this delta with ``⚡`` to show the reset crossing
212
+ without hiding the informative number.
213
+ - ``None`` only when ``current_used_pct`` is unknown OR the
214
+ block-start anchor is missing AND no post-reset anchor was found.
215
+ """
216
+ snap = conn.execute(
217
+ """
218
+ SELECT five_hour_window_key, week_start_at
219
+ FROM weekly_usage_snapshots
220
+ WHERE captured_at_utc <= ?
221
+ ORDER BY captured_at_utc DESC, id DESC
222
+ LIMIT 1
223
+ """,
224
+ (_iso_z(now_utc),),
225
+ ).fetchone()
226
+ if snap is None or snap["five_hour_window_key"] is None:
227
+ return None
228
+
229
+ block = conn.execute(
230
+ """
231
+ SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
232
+ seven_day_pct_at_block_start,
233
+ crossed_seven_day_reset
234
+ FROM five_hour_blocks
235
+ WHERE five_hour_window_key = ?
236
+ AND is_closed = 0
237
+ AND five_hour_resets_at > ?
238
+ """,
239
+ (snap["five_hour_window_key"], _iso_z(now_utc)),
240
+ ).fetchone()
241
+ if block is None:
242
+ return None
243
+
244
+ crossed = bool(block["crossed_seven_day_reset"])
245
+ p_start = block["seven_day_pct_at_block_start"]
246
+
247
+ # When the block crossed a weekly reset, recompute the delta against
248
+ # the first post-reset snapshot inside the block instead of the
249
+ # pre-reset block-start anchor. Use ``unixepoch()`` because
250
+ # ``block_start_at`` is host-local-tz (``+03:00``) while
251
+ # ``captured_at_utc`` is canonical UTC-Z; lex compares mis-order
252
+ # mixed-offset moments. ``snap.week_start_at`` is the latest
253
+ # (post-reset) week's anchor, so equality on that column scopes
254
+ # the lookup to the current weekly window.
255
+ p_anchor = p_start
256
+ if crossed and snap["week_start_at"] is not None:
257
+ post = conn.execute(
258
+ """
259
+ SELECT weekly_percent
260
+ FROM weekly_usage_snapshots
261
+ WHERE week_start_at = ?
262
+ AND unixepoch(captured_at_utc) >= unixepoch(?)
263
+ AND unixepoch(captured_at_utc) <= unixepoch(?)
264
+ ORDER BY captured_at_utc ASC, id ASC
265
+ LIMIT 1
266
+ """,
267
+ (
268
+ snap["week_start_at"],
269
+ block["block_start_at"],
270
+ _iso_z(now_utc),
271
+ ),
272
+ ).fetchone()
273
+ if post is not None and post["weekly_percent"] is not None:
274
+ p_anchor = float(post["weekly_percent"])
275
+
276
+ delta = (
277
+ None if (p_anchor is None or current_used_pct is None)
278
+ else round(current_used_pct - p_anchor, 9)
279
+ )
280
+
281
+ # Spec §5.3 — in-place credit events for this 5h block's window,
282
+ # ascending by ``effective_reset_at_utc``. Drives the
283
+ # ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
284
+ # ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
285
+ # Snake_case keys to match the envelope convention (see CLAUDE.md;
286
+ # CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
287
+ cred_rows = conn.execute(
288
+ """
289
+ SELECT effective_reset_at_utc, prior_percent, post_percent
290
+ FROM five_hour_reset_events
291
+ WHERE five_hour_window_key = ?
292
+ ORDER BY effective_reset_at_utc ASC
293
+ """,
294
+ (int(block["five_hour_window_key"]),),
295
+ ).fetchall()
296
+ credits = [
297
+ {
298
+ "effective_reset_at_utc": c["effective_reset_at_utc"],
299
+ "prior_percent": float(c["prior_percent"]),
300
+ "post_percent": float(c["post_percent"]),
301
+ "delta_pp": round(
302
+ float(c["post_percent"]) - float(c["prior_percent"]), 1
303
+ ),
304
+ }
305
+ for c in cred_rows
306
+ ]
307
+
308
+ return {
309
+ "block_start_at": block["block_start_at"],
310
+ "five_hour_window_key": int(block["five_hour_window_key"]),
311
+ "seven_day_pct_at_block_start": p_start,
312
+ "seven_day_pct_delta_pp": delta,
313
+ "crossed_seven_day_reset": crossed,
314
+ "credits": credits,
315
+ }
316
+
317
+
318
+ # === Alerts-envelope per-axis row-mappers (Task F) =========================
319
+ # Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
320
+ # the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
321
+ # (Codex P0-3: distinct columns, JOINs, id shapes), so the registry unifies the
322
+ # *set* of axes + their ``milestone_table`` + the shared ``severity_for``
323
+ # authority, not the query itself. ``descriptor.milestone_table`` drives each
324
+ # ``FROM`` clause so the table name lives in the registry, not inlined here.
325
+
326
+
327
+ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
328
+ # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
329
+ # across pre-credit (0) and post-credit (event.id) cohorts, both
330
+ # of which can be alerted. The envelope id must include the
331
+ # segment so React's <li key={a.id}> / <tr key={a.id}> doesn't
332
+ # collide on the duplicate (week, threshold) pair. Older clients
333
+ # tolerate longer ids — the id is opaque to them; only the React
334
+ # key uniqueness invariant matters.
335
+ rows = conn.execute(
336
+ f"""
337
+ SELECT week_start_date, percent_threshold, captured_at_utc,
338
+ alerted_at, cumulative_cost_usd, reset_event_id
339
+ FROM {descriptor.milestone_table}
340
+ WHERE alerted_at IS NOT NULL
341
+ ORDER BY alerted_at DESC
342
+ LIMIT ?
343
+ """,
344
+ (limit,),
345
+ ).fetchall()
346
+ out: list[dict] = []
347
+ for r in rows:
348
+ threshold = int(r["percent_threshold"])
349
+ cumulative = float(r["cumulative_cost_usd"])
350
+ dpp = (cumulative / threshold) if threshold else None
351
+ out.append({
352
+ "id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
353
+ "axis": descriptor.id,
354
+ "threshold": threshold,
355
+ "severity": severity_for(threshold),
356
+ "crossed_at": r["captured_at_utc"],
357
+ "alerted_at": r["alerted_at"],
358
+ "context": {
359
+ "week_start_date": r["week_start_date"],
360
+ "cumulative_cost_usd": cumulative,
361
+ "dollars_per_percent": dpp,
362
+ # Round-3: parallel to the 5h context block below — both
363
+ # axes now expose ``reset_event_id`` so downstream
364
+ # clients (panel, modal, third-party consumers) can
365
+ # discriminate pre- vs post-credit crossings of the
366
+ # same (week, threshold) without scraping the
367
+ # envelope ``id`` string. 0 = pre-credit / no-event;
368
+ # event.id = post-credit segment.
369
+ "reset_event_id": int(r["reset_event_id"]),
370
+ },
371
+ })
372
+ return out
373
+
374
+
375
+ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
376
+ # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
377
+ # include ``reset_event_id`` so post-credit (seg=event.id) crossings
378
+ # of the same (window_key, threshold) don't collide with pre-credit
379
+ # (seg=0) crossings on the React row key. Older clients tolerate
380
+ # longer ids — the id is opaque to them; only the React key
381
+ # uniqueness invariant matters. Mirrors the weekly precedent.
382
+ rows = conn.execute(
383
+ f"""
384
+ SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
385
+ m.alerted_at, m.block_cost_usd, m.reset_event_id,
386
+ b.block_start_at
387
+ FROM {descriptor.milestone_table} m
388
+ LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
389
+ WHERE m.alerted_at IS NOT NULL
390
+ ORDER BY m.alerted_at DESC
391
+ LIMIT ?
392
+ """,
393
+ (limit,),
394
+ ).fetchall()
395
+ out: list[dict] = []
396
+ for r in rows:
397
+ threshold = int(r["percent_threshold"])
398
+ out.append({
399
+ "id": (
400
+ f"five_hour:{int(r['five_hour_window_key'])}:"
401
+ f"{threshold}:{int(r['reset_event_id'])}"
402
+ ),
403
+ "axis": descriptor.id,
404
+ "threshold": threshold,
405
+ "severity": severity_for(threshold),
406
+ "crossed_at": r["captured_at_utc"],
407
+ "alerted_at": r["alerted_at"],
408
+ "context": {
409
+ "five_hour_window_key": int(r["five_hour_window_key"]),
410
+ "block_start_at": r["block_start_at"] or "",
411
+ "block_cost_usd": float(r["block_cost_usd"] or 0.0),
412
+ "reset_event_id": int(r["reset_event_id"]),
413
+ },
414
+ })
415
+ return out
416
+
417
+
418
+ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[dict]:
419
+ # Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
420
+ # ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
421
+ # (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
422
+ # ``descriptor.vendor`` drives the ``WHERE vendor=?`` row filter + the
423
+ # ``COALESCE(period, <vendor-default-noun>)`` default, ``descriptor.id`` the
424
+ # envelope id prefix, ``descriptor.milestone_table`` (now ``budget_milestones``
425
+ # for both) the source table.
426
+ #
427
+ # Budget alerts are keyed by ``period_start_at`` (the resolved period-window
428
+ # start instant — a subscription-week start for claude OR a calendar
429
+ # period-start for codex) + the write-once ``period`` discriminator (#137) +
430
+ # the integer threshold. No ``reset_event_id`` segment: a mid-week reset
431
+ # (claude) or a period rollover (codex) re-anchors ``period_start_at`` so the
432
+ # new window naturally gets fresh rows under
433
+ # ``UNIQUE(vendor, period_start_at, period, threshold)``.
434
+ #
435
+ # All numbers + the ``period`` noun are read FROM THE ROW (snapshotted at
436
+ # crossing), never live config that may have changed since (the Codex P0-4
437
+ # lesson; Symptom 1 fix) — a user who fires alerts then switches
438
+ # ``budget.period`` keeps the historical row's noun. ``COALESCE(period, …)``
439
+ # renders a pre-011 NULL-sentinel row with the vendor-default noun and keeps
440
+ # the ``id`` non-``None``; the id's ``period`` segment gives a calendar-week ↔
441
+ # calendar-month coinciding-instant collision (now distinct coexisting rows)
442
+ # distinct React keys.
443
+ #
444
+ # Byte-stable identity: the envelope id ==
445
+ # ``f"{descriptor.id}:{period_start_at}:{period}:{threshold}"`` matches the
446
+ # pre-#143 ``budget:…`` / ``codex_budget:…`` strings exactly (same instant
447
+ # value, renamed key column). The per-vendor context dict KEY ORDER matches
448
+ # the pre-#143 envelopes verbatim: the claude/``budget`` axis still emits BOTH
449
+ # the legacy ``week_start_at`` AND ``period_start_at`` (same instant value) so
450
+ # no existing TS consumer of ``context.week_start_at`` breaks; the
451
+ # codex/``codex_budget`` axis emits ``period_start_at`` only.
452
+ vendor = descriptor.vendor
453
+ default_noun = "subscription-week" if vendor == "claude" else "calendar-month"
454
+ rows = conn.execute(
455
+ f"""
456
+ SELECT period_start_at,
457
+ COALESCE(period, ?) AS period,
458
+ threshold, crossed_at_utc, alerted_at,
459
+ budget_usd, spent_usd, consumption_pct
460
+ FROM {descriptor.milestone_table}
461
+ WHERE vendor = ? AND alerted_at IS NOT NULL
462
+ ORDER BY alerted_at DESC
463
+ LIMIT ?
464
+ """,
465
+ (default_noun, vendor, limit),
466
+ ).fetchall()
467
+ out: list[dict] = []
468
+ for r in rows:
469
+ threshold = int(r["threshold"])
470
+ if vendor == "claude":
471
+ ctx = { # key order byte-stable with the pre-#143 budget envelope
472
+ "week_start_at": r["period_start_at"],
473
+ "period": r["period"],
474
+ "period_start_at": r["period_start_at"],
475
+ "budget_usd": float(r["budget_usd"]),
476
+ "spent_usd": float(r["spent_usd"]),
477
+ "consumption_pct": float(r["consumption_pct"]),
478
+ }
479
+ else:
480
+ ctx = { # key order byte-stable with the pre-#143 codex_budget envelope
481
+ "period": r["period"],
482
+ "period_start_at": r["period_start_at"],
483
+ "budget_usd": float(r["budget_usd"]),
484
+ "spent_usd": float(r["spent_usd"]),
485
+ "consumption_pct": float(r["consumption_pct"]),
486
+ }
487
+ out.append({
488
+ "id": (
489
+ f"{descriptor.id}:{r['period_start_at']}:{r['period']}:{threshold}"
490
+ ),
491
+ "axis": descriptor.id,
492
+ "threshold": threshold,
493
+ "severity": severity_for(threshold),
494
+ "crossed_at": r["crossed_at_utc"],
495
+ "alerted_at": r["alerted_at"],
496
+ "context": ctx,
497
+ })
498
+ return out
499
+
500
+
501
+ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
502
+ # Fourth axis (issue #121): projected-pace threshold crossings. Like
503
+ # budget, projected alerts re-anchor ``week_start_at`` on a mid-week
504
+ # reset, so there is NO ``reset_event_id`` segment — the new window gets
505
+ # fresh rows under ``UNIQUE(week_start_at, period, metric, threshold)``. The
506
+ # ``metric`` discriminator (``weekly_pct`` | ``budget_usd`` |
507
+ # ``codex_budget_usd``) drives the frontend's metric-aware context renderer;
508
+ # ``denominator`` + ``projected_value`` are rendered FROM THE ROW (the values
509
+ # snapshotted at crossing), never live config that may have changed since
510
+ # (Codex P0-4). The envelope id mirrors the dispatch payload's
511
+ # ``projected:<week_start_at>:<period>:<metric>:<threshold>`` shape. The
512
+ # write-once ``period`` discriminator (#137) carries no symptom-1 label here
513
+ # — projected's ``context`` is metric-driven, never a live-config period
514
+ # noun — so ``COALESCE(period, 'subscription-week')`` is purely for a stable
515
+ # non-``None`` id segment on a pre-011 NULL-sentinel row (the calendar-week ↔
516
+ # calendar-month within-metric collision otherwise shares a React key).
517
+ rows = conn.execute(
518
+ f"""
519
+ SELECT week_start_at,
520
+ COALESCE(period, 'subscription-week') AS period,
521
+ metric, threshold, projected_value,
522
+ denominator, crossed_at_utc, alerted_at
523
+ FROM {descriptor.milestone_table}
524
+ WHERE alerted_at IS NOT NULL
525
+ ORDER BY alerted_at DESC
526
+ LIMIT ?
527
+ """,
528
+ (limit,),
529
+ ).fetchall()
530
+ out: list[dict] = []
531
+ for r in rows:
532
+ threshold = int(r["threshold"])
533
+ metric = str(r["metric"])
534
+ out.append({
535
+ "id": (
536
+ f"projected:{r['week_start_at']}:{r['period']}"
537
+ f":{metric}:{threshold}"
538
+ ),
539
+ "axis": descriptor.id,
540
+ "metric": metric,
541
+ "threshold": threshold,
542
+ "severity": severity_for(threshold),
543
+ "crossed_at": r["crossed_at_utc"],
544
+ "alerted_at": r["alerted_at"],
545
+ "context": {
546
+ "week_start_at": r["week_start_at"],
547
+ "metric": metric,
548
+ "projected_value": float(r["projected_value"]),
549
+ "denominator": float(r["denominator"]),
550
+ },
551
+ })
552
+ return out
553
+
554
+
555
+ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
556
+ # Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
557
+ # crossings. Like the global budget axis, project-budget alerts re-anchor
558
+ # ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
559
+ # segment — the new window gets fresh rows under
560
+ # ``UNIQUE(week_start_at, project_key, threshold)``. ``project_key`` is the
561
+ # canonical git-root (``ProjectKey.bucket_path``); the human-readable chip
562
+ # context carries the project BASENAME, resolved through the production
563
+ # ``_resolve_project_key`` (git-root mode) so a moved/deleted repo still
564
+ # renders its basename from the snapshotted path (no FS dependency on a
565
+ # live ``.git``). ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
566
+ # rendered FROM THE ROW (snapshotted at crossing), never live config that
567
+ # may have changed since (Codex P0-4). The envelope id mirrors the dispatch
568
+ # payload's ``project_budget:<week_start_at>:<project_key>:<threshold>``
569
+ # shape (``_build_alert_payload_project_budget``).
570
+ rows = conn.execute(
571
+ f"""
572
+ SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
573
+ consumption_pct, crossed_at_utc, alerted_at
574
+ FROM {descriptor.milestone_table}
575
+ WHERE alerted_at IS NOT NULL
576
+ ORDER BY alerted_at DESC
577
+ LIMIT ?
578
+ """,
579
+ (limit,),
580
+ ).fetchall()
581
+ c = sys.modules["cctally"]
582
+ # Collision-aware labels via the shared primitive (#130), disambiguated
583
+ # across the alerted ROWS (NOT live config) to preserve the
584
+ # render-from-the-snapshotted-row invariant above — so a deleted/renamed
585
+ # config key still renders. This is intentionally a different feed than the
586
+ # table/notification (full config); see spec §1 Goals (Codex F1).
587
+ label_by_key = c._project_budget_labels(
588
+ sorted({r["project_key"] for r in rows})
589
+ )
590
+ out: list[dict] = []
591
+ for r in rows:
592
+ threshold = int(r["threshold"])
593
+ project_key = r["project_key"]
594
+ out.append({
595
+ "id": (
596
+ f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
597
+ ),
598
+ "axis": descriptor.id,
599
+ "threshold": threshold,
600
+ "severity": severity_for(threshold),
601
+ "crossed_at": r["crossed_at_utc"],
602
+ "alerted_at": r["alerted_at"],
603
+ "context": {
604
+ "week_start_at": r["week_start_at"],
605
+ "project": label_by_key.get(project_key, project_key),
606
+ "project_key": project_key,
607
+ "budget_usd": float(r["budget_usd"]),
608
+ "spent_usd": float(r["spent_usd"]),
609
+ "consumption_pct": float(r["consumption_pct"]),
610
+ },
611
+ })
612
+ return out
613
+
614
+
615
+ # Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
616
+ # in what order; this table supplies the bespoke heterogeneous row-mapper.
617
+ # ``budget`` (vendor='claude') and ``codex_budget`` (vendor='codex') share the
618
+ # one ``_envelope_rows_budget_family`` mapper (#143) — it reads
619
+ # ``descriptor.vendor`` for the row filter + default noun and ``descriptor.id``
620
+ # for the envelope id prefix, so the two axes stay distinct on the wire.
621
+ _ENVELOPE_AXIS_MAPPERS = {
622
+ "weekly": _envelope_rows_weekly,
623
+ "five_hour": _envelope_rows_five_hour,
624
+ "budget": _envelope_rows_budget_family,
625
+ "projected": _envelope_rows_projected,
626
+ "project_budget": _envelope_rows_project_budget,
627
+ "codex_budget": _envelope_rows_budget_family,
628
+ }
629
+
630
+
631
+ def _build_alerts_envelope_array(
632
+ conn: sqlite3.Connection,
633
+ limit: int = 100,
634
+ ) -> list[dict]:
635
+ """Return the ``alerts`` array for the SSE snapshot envelope.
636
+
637
+ Union of ``percent_milestones``, ``five_hour_milestones``,
638
+ ``budget_milestones`` (vendor-tagged — backs BOTH the ``budget`` and
639
+ ``codex_budget`` axes since #143), ``projected_milestones``, and
640
+ ``project_budget_milestones`` rows with
641
+ ``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
642
+ ``limit`` (default 100). Single source of truth for both the dashboard panel
643
+ (slices to 10 client-side) and the modal (renders all 100). Forward-only
644
+ semantics: only rows the alert-dispatch path stamped get included; pre-deploy
645
+ crossings stay NULL and are intentionally invisible (spec §4.3).
646
+
647
+ All six axes share the same envelope schema; the ``axis`` field
648
+ (``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
649
+ ``project_budget`` / ``codex_budget``) discriminates. The ``projected`` axis
650
+ additionally carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
651
+ | ``codex_budget_usd``) so the frontend can pick its metric-aware context
652
+ renderer; ``project_budget``
653
+ carries the project basename + ``$spent of $budget`` in its context;
654
+ ``budget`` + ``codex_budget`` carry a ``period`` discriminator
655
+ (subscription-week / calendar-week / calendar-month) so the frontend renders
656
+ a period-aware "Month" / "Calendar week" / "Week" label.
657
+
658
+ Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
659
+ up to ``limit``) and the union is re-sorted + sliced — important for
660
+ the boundary case where one axis has ``limit`` rows and the other
661
+ has more recent ones that would otherwise be dropped before the
662
+ final sort.
663
+
664
+ **Registry-driven (Task F).** The *set* of axes, their union *order*,
665
+ and each axis's ``milestone_table`` come from
666
+ ``_lib_alert_axes.AXIS_REGISTRY`` — adding a future axis is "register a
667
+ descriptor + add a row-mapper", not "hand-roll a parallel branch". The
668
+ SQL stays genuinely heterogeneous per axis (Codex P0-3: different
669
+ columns, JOINs, id shapes), so each descriptor pairs with a bespoke
670
+ row-mapper keyed by ``descriptor.id`` in ``_ENVELOPE_AXIS_MAPPERS``. The
671
+ shared ``severity_for`` kernel stamps the additive ``severity`` field on
672
+ every item (single severity authority, consumed by the frontend too).
673
+ """
674
+ c = sys.modules["cctally"]
675
+ registry = c.AXIS_REGISTRY
676
+ severity_for = c.severity_for
677
+ out: list[dict] = []
678
+ for descriptor in registry:
679
+ mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
680
+ if mapper is None: # pragma: no cover - registry/mapper drift guard
681
+ continue
682
+ out.extend(mapper(conn, descriptor, limit, severity_for))
683
+
684
+ # Python's list.sort is stable. When two alerts share the same
685
+ # `alerted_at` ISO string (rare; multiple axes firing within the same
686
+ # millisecond), the union order (weekly, then 5h, then budget, then
687
+ # projected) determines the tiebreaker — no extra deterministic key is
688
+ # added because the spec doesn't require one.
689
+ out.sort(key=lambda a: a["alerted_at"], reverse=True)
690
+ return out[:limit]
691
+
692
+
693
+ def snapshot_to_envelope(snap: "DataSnapshot", *,
694
+ now_utc: "dt.datetime",
695
+ monotonic_now: "float | None" = None,
696
+ oauth_usage_cfg: "dict | None" = None,
697
+ display_tz_pref_override: "str | None" = None,
698
+ runtime_bind: "str | None" = None,
699
+ transcripts_visible: bool = False) -> dict:
700
+ """Serialize a DataSnapshot into the JSON envelope consumed by the
701
+ browser (design spec §2.2).
702
+
703
+ ``transcripts_visible`` gates the transcript-derived session ``title``
704
+ (#264 S3): the key is emitted ONLY when the flag is True AND the row has
705
+ a title, so False (the default) fails closed for any caller that forgets
706
+ to pass it — no ``title`` key, no leaked prompt content. The two
707
+ browser-serving emit sites (``GET /api/data`` + the SSE loop) pass the
708
+ per-request ``_transcripts_visible_to_request()`` — the SAME predicate
709
+ that drives ``transcriptsEnabled`` and the per-row "open conversation"
710
+ button; every other caller (share builders, fixtures, tests) keeps the
711
+ default. ``cache_hit_pct`` (sessions) and ``cost_usd`` (trend) are plain
712
+ numbers and are never gated.
713
+
714
+ Pure function — no I/O on the snapshot data path. Reads
715
+ ``config.json`` once for ``display.tz`` and once for the
716
+ ``alerts_settings`` mirror (cheap atomic-rename reads); the
717
+ actual ``alerts`` array comes from the precomputed
718
+ ``snap.alerts`` already populated by the sync thread, so
719
+ rendering never touches the DB.
720
+
721
+ ``now_utc`` is used for wall-clock age computations (``reset_in_sec``,
722
+ ``last_snapshot_age_sec``, etc.). ``monotonic_now`` is the caller's
723
+ ``time.monotonic()`` snapshot, used only to compute ``sync_age_s``
724
+ against ``snap.last_sync_at`` (a monotonic-clock reading on the real
725
+ DataSnapshot). Pass ``None`` when no sync has happened yet.
726
+
727
+ ``oauth_usage_cfg`` is the resolved oauth_usage block (from
728
+ ``_get_oauth_usage_config(load_config())``) used for freshness-label
729
+ thresholds. Callers MUST pass a pre-resolved cfg to keep this
730
+ function pure (no per-tick FS reads on the dashboard hot path);
731
+ when ``None``, falls back to ``_OAUTH_USAGE_DEFAULTS`` directly so
732
+ pure-construction tests don't need to plumb config.
733
+
734
+ Panels that have no data serialize as None so the JS client can
735
+ render placeholders without special-casing.
736
+
737
+ Field mapping (envelope key → DataSnapshot attribute):
738
+ * ``current_week.*`` ← ``TuiCurrentWeek`` (dollars_per_percent,
739
+ five_hour_resets_at, latest_snapshot_at, spent_usd, used_pct,
740
+ five_hour_pct). ``week_label`` and ``reset_at_utc`` are
741
+ synthesized from ``week_start_at`` / ``week_end_at``.
742
+ * ``forecast.*`` ← ``ForecastOutput`` (inputs.confidence,
743
+ inputs.dollars_per_percent, r_avg / r_recent, inputs.p_now /
744
+ remaining_hours, budgets[]). ``week_avg_projection_pct`` and
745
+ ``recent_24h_projection_pct`` are computed per-method from their
746
+ source rates so labels stay correct on decelerating weeks
747
+ (r_recent < r_avg); ``recent_24h`` is omitted when ``r_recent``
748
+ is None or the two projections match. ``budget_100_per_day_usd``
749
+ / ``budget_90_per_day_usd`` pick the matching
750
+ ``BudgetRow.dollars_per_day``.
751
+ * ``trend.weeks[]`` ← ``snap.trend`` (the 8-week panel dataset,
752
+ ``TuiTrendRow``: week_label, used_pct, dollars_per_percent,
753
+ delta_dpp, is_current, spark_height). Preserves the envelope key
754
+ names (``label``, ``dollar_per_pct``, ``delta``) that the JS
755
+ consumes. Do NOT swap this for ``snap.weekly_history`` — that
756
+ field holds up to 12 rows for the modal (``trend.history[]``
757
+ below) and would overflow the panel's hard-coded "(8 weeks)"
758
+ header if used for ``weeks[]``.
759
+ * ``trend.history[]`` ← ``snap.weekly_history`` (up to 12 rows,
760
+ same TuiTrendRow shape as ``weeks[]``). v2-only: consumed by
761
+ the Trend detail modal. Sibling to ``weeks[]``, not a
762
+ replacement.
763
+ * ``sessions.rows[]`` ← ``TuiSessionRow`` (session_id, started_at,
764
+ duration_minutes, model_primary, project_label, cost_usd).
765
+ """
766
+ cw = snap.current_week
767
+ fc = snap.forecast
768
+ # Issue #57 — prefer ``snap.forecast_view`` (precomputed by the
769
+ # sync thread via ``build_forecast_view``) over re-deriving the
770
+ # projection / verdict / header-routing / budget fields inline.
771
+ # Falls back to the legacy inline routing below when
772
+ # ``forecast_view`` is missing — fixture modules that construct
773
+ # ``DataSnapshot`` positionally without the post-Bundle-1 fields
774
+ # leave it at ``None``, and their goldens predate the View so
775
+ # keeping the legacy path under that fallback preserves byte
776
+ # stability.
777
+ fc_view = getattr(snap, "forecast_view", None)
778
+
779
+ # F1 fix: server-resolve the display tz to a CONCRETE IANA name and
780
+ # surface it on the envelope so the browser never has to guess "local".
781
+ # Reused below for week_lbl / blocks / monthly label rendering so the
782
+ # whole envelope speaks one zone consistently.
783
+ # F3 fix: when the dashboard was started with `--tz <X>`, the override
784
+ # supersedes the persisted config.display.tz for the lifetime of the
785
+ # process. The override flows in as a canonical tz token; we layer it
786
+ # onto the config dict via _apply_display_tz_override before resolving
787
+ # so every reader downstream sees one zone.
788
+ # #268 M4: read the config + update-state + doctor from the sync-thread
789
+ # precompute (spec §6) so this stays a PURE renderer — no config.json read,
790
+ # no `security` fork, no update-state file read per SSE client per tick.
791
+ # When the fields are absent (fixtures / the initial empty snapshot /
792
+ # positionally-constructed DataSnapshots) fall back to the inline reads so
793
+ # behavior is byte-identical for those callers.
794
+ _precomp = getattr(snap, "envelope_precompute", None)
795
+ _raw_config = _precomp["config"] if _precomp is not None else sys.modules["cctally"].load_config()
796
+ config = sys.modules["cctally"]._apply_display_tz_override(
797
+ _raw_config, display_tz_pref_override
798
+ )
799
+ display_block = _compute_display_block(config, snap.generated_at)
800
+ resolved_tz_obj = ZoneInfo(display_block["resolved_tz"])
801
+
802
+ if snap.last_sync_at is not None and monotonic_now is not None:
803
+ sync_age_s = max(0, int(monotonic_now - snap.last_sync_at))
804
+ else:
805
+ sync_age_s = None
806
+
807
+ # Header is a thin projection of the other panels — the JS can read
808
+ # from current_week / forecast directly, but pre-composing it lets
809
+ # tools like `curl` read a self-contained envelope.
810
+ used_pct = getattr(cw, "used_pct", None) if cw is not None else None
811
+ five_hr = getattr(cw, "five_hour_pct", None) if cw is not None else None
812
+ dollar_pp = getattr(cw, "dollars_per_percent", None) if cw is not None else None
813
+
814
+ # Forecast field routing (issue #57). ``snap.forecast_view`` is the
815
+ # single source of truth: ``build_forecast_view`` runs the per-
816
+ # method projection / verdict / budget routing once and stashes
817
+ # the surface fields on the View. The legacy inline derivation
818
+ # remains as a fallback for fixture modules that construct
819
+ # ``DataSnapshot`` positionally without populating ``forecast_view``
820
+ # — their goldens predate the View, and the legacy block emits
821
+ # the same numbers so byte stability is preserved.
822
+ fcast_pct: "float | None" = None
823
+ recent_24h_pct: "float | None" = None
824
+ verdict: "str | None" = None
825
+ confidence: "str | None" = None
826
+ budget_100: "float | None" = None
827
+ budget_90: "float | None" = None
828
+ if fc_view is not None:
829
+ fcast_pct = fc_view.week_avg_projection_pct
830
+ recent_24h_pct = fc_view.recent_24h_projection_pct
831
+ # ForecastView.dashboard_verdict / .confidence default to
832
+ # ``"ok"`` / ``"unknown"`` even when ``output is None``; only
833
+ # surface non-None envelope values when there's an actual
834
+ # ForecastOutput backing them so the existing
835
+ # ``verdict / confidence is None when fc is None`` shape stays.
836
+ verdict = fc_view.dashboard_verdict if fc is not None else None
837
+ confidence = fc_view.confidence if fc is not None else None
838
+ budget_100 = fc_view.budget_100_per_day_usd
839
+ budget_90 = fc_view.budget_90_per_day_usd
840
+ elif fc is not None:
841
+ # Legacy inline routing — kept verbatim for positionally-
842
+ # constructed fixture snapshots that don't carry ``forecast_view``.
843
+ inputs = getattr(fc, "inputs", None)
844
+ if inputs is not None:
845
+ confidence = getattr(inputs, "confidence", None)
846
+ r_avg = getattr(fc, "r_avg", None)
847
+ r_recent = getattr(fc, "r_recent", None)
848
+ p_now = getattr(inputs, "p_now", None) if inputs is not None else None
849
+ rem_hrs = getattr(inputs, "remaining_hours", None) if inputs is not None else None
850
+ if p_now is not None and rem_hrs is not None and r_avg is not None:
851
+ fcast_pct = p_now + r_avg * rem_hrs
852
+ if (p_now is not None and rem_hrs is not None
853
+ and r_recent is not None):
854
+ p_final_recent = p_now + r_recent * rem_hrs
855
+ if fcast_pct is None or p_final_recent != fcast_pct:
856
+ recent_24h_pct = p_final_recent
857
+ if getattr(fc, "already_capped", False):
858
+ verdict = "capped"
859
+ elif getattr(fc, "projected_cap", False):
860
+ verdict = "cap"
861
+ else:
862
+ verdict = "ok"
863
+ for b in getattr(fc, "budgets", []) or []:
864
+ tp = getattr(b, "target_percent", None)
865
+ dpd = getattr(b, "dollars_per_day", None)
866
+ if tp == 100:
867
+ budget_100 = dpd
868
+ elif tp == 90:
869
+ budget_90 = dpd
870
+
871
+ # Freshness envelope for current_week — derived from
872
+ # cw.latest_snapshot_at (a datetime, not an ISO string). None when
873
+ # cw is absent or has no snapshot timestamp; the React client renders
874
+ # a placeholder in that case. Refs spec §3.4.
875
+ cw_freshness: "dict | None" = None
876
+ if cw is not None and cw.latest_snapshot_at is not None:
877
+ captured = cw.latest_snapshot_at
878
+ if captured.tzinfo is None:
879
+ captured = captured.replace(tzinfo=dt.timezone.utc)
880
+ age_s = max(0.0, (now_utc - captured).total_seconds())
881
+ _fresh_cfg = (
882
+ oauth_usage_cfg if oauth_usage_cfg is not None
883
+ else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
884
+ )
885
+ cw_freshness = {
886
+ "label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
887
+ "captured_at": _iso_z(captured),
888
+ "age_seconds": int(age_s),
889
+ }
890
+
891
+ week_lbl: "str | None" = None
892
+ reset_at_utc: "dt.datetime | None" = None
893
+ if cw is not None:
894
+ ws = getattr(cw, "week_start_at", None)
895
+ we = getattr(cw, "week_end_at", None)
896
+ # Full window "Apr 13–20" so the dashboard matches the TUI/report
897
+ # headers and doesn't hide month crossings (e.g. "Apr 27–May 03").
898
+ # En-dash U+2013 matches the TUI format at cmd_tui.
899
+ if ws is not None and we is not None:
900
+ week_lbl = (
901
+ f"{format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)}"
902
+ f"–"
903
+ f"{format_display_dt(we, resolved_tz_obj, fmt='%b %d', suffix=False)}"
904
+ )
905
+ elif ws is not None:
906
+ week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
907
+ reset_at_utc = we
908
+
909
+ # Header forecast_pct should match the projection that drove the
910
+ # verdict pill next to it. The View (issue #57) carries the
911
+ # already-routed ``header_projection_pct``; the fallback path
912
+ # replays the legacy routing inline for fixture snapshots that
913
+ # don't populate ``forecast_view``. The Forecast panel still
914
+ # exposes both ``week_avg_projection_pct`` and
915
+ # ``recent_24h_projection_pct`` unchanged.
916
+ if fc_view is not None and fc is not None:
917
+ header_fcast_pct = fc_view.header_projection_pct
918
+ else:
919
+ header_fcast_pct = fcast_pct
920
+ if (
921
+ verdict in ("cap", "capped")
922
+ and recent_24h_pct is not None
923
+ and fcast_pct is not None
924
+ and recent_24h_pct > fcast_pct
925
+ ):
926
+ header_fcast_pct = recent_24h_pct
927
+
928
+ # ---- weekly / monthly periods ---------------------------------
929
+ def _weekly_row_to_dict(r: "WeeklyPeriodRow") -> dict:
930
+ return {
931
+ "label": r.label,
932
+ "cost_usd": r.cost_usd,
933
+ "total_tokens": r.total_tokens,
934
+ "input_tokens": r.input_tokens,
935
+ "output_tokens": r.output_tokens,
936
+ "cache_creation_tokens": r.cache_creation_tokens,
937
+ "cache_read_tokens": r.cache_read_tokens,
938
+ "used_pct": r.used_pct,
939
+ "dollar_per_pct": r.dollar_per_pct,
940
+ "delta_cost_pct": r.delta_cost_pct,
941
+ "is_current": r.is_current,
942
+ "models": list(r.models),
943
+ "week_start_at": r.week_start_at,
944
+ "week_end_at": r.week_end_at,
945
+ }
946
+
947
+ def _monthly_row_to_dict(r: "MonthlyPeriodRow") -> dict:
948
+ return {
949
+ "label": r.label,
950
+ "cost_usd": r.cost_usd,
951
+ "total_tokens": r.total_tokens,
952
+ "input_tokens": r.input_tokens,
953
+ "output_tokens": r.output_tokens,
954
+ "cache_creation_tokens": r.cache_creation_tokens,
955
+ "cache_read_tokens": r.cache_read_tokens,
956
+ "delta_cost_pct": r.delta_cost_pct,
957
+ "is_current": r.is_current,
958
+ "models": list(r.models),
959
+ }
960
+
961
+ def _blocks_row_to_dict(r: "BlocksPanelRow") -> dict:
962
+ return {
963
+ "start_at": r.start_at,
964
+ "end_at": r.end_at,
965
+ "anchor": r.anchor,
966
+ "is_active": r.is_active,
967
+ "cost_usd": r.cost_usd,
968
+ "models": list(r.models),
969
+ "label": r.label,
970
+ }
971
+
972
+ def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
973
+ return {
974
+ "date": r.date,
975
+ "label": r.label,
976
+ "cost_usd": r.cost_usd,
977
+ "is_today": r.is_today,
978
+ "intensity_bucket": r.intensity_bucket,
979
+ "models": list(r.models),
980
+ # ---- v2.3 additions ----
981
+ "input_tokens": r.input_tokens,
982
+ "output_tokens": r.output_tokens,
983
+ "cache_creation_tokens": r.cache_creation_tokens,
984
+ "cache_read_tokens": r.cache_read_tokens,
985
+ "total_tokens": r.total_tokens,
986
+ "cache_hit_pct": r.cache_hit_pct,
987
+ }
988
+
989
+ # Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
990
+ # Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
991
+ # can distinguish "loading / not synced yet" (null parent) from
992
+ # "synced + no data" (empty rows). For Weekly/Monthly, sync always
993
+ # builds the rows list (even if empty), so we always emit the object.
994
+ weekly_env = {
995
+ "rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods],
996
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
997
+ # totals. Sourced from sum-over-visible-rows in the sync thread
998
+ # (``_tui_build_snapshot``) so the footer total is structurally
999
+ # equal to the React panel's ``rows.reduce(...)``. The earlier
1000
+ # ``build_weekly_view``-sourced totals undercounted Bug-K
1001
+ # pre-credit synthesized rows on credit weeks; the regression is
1002
+ # captured by
1003
+ # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
1004
+ "total_cost_usd": snap.weekly_total_cost_usd,
1005
+ "total_tokens": snap.weekly_total_tokens,
1006
+ }
1007
+ monthly_env = {
1008
+ "rows": [_monthly_row_to_dict(r) for r in snap.monthly_periods],
1009
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
1010
+ # totals via sum-over-visible-rows so the React MonthlyPanel's
1011
+ # smoking-gun ``rows.reduce(...)`` collapses to a single envelope
1012
+ # read with the structural-equality invariant preserved.
1013
+ "total_cost_usd": snap.monthly_total_cost_usd,
1014
+ "total_tokens": snap.monthly_total_tokens,
1015
+ }
1016
+
1017
+ blocks_env = {
1018
+ "rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel],
1019
+ # View-model unification follow-up (issue #56): additive scalars
1020
+ # so the React BlocksPanel can stop running `rows.reduce(...)`
1021
+ # in JS. Cost is summed-over-visible-rows in
1022
+ # `_dashboard_build_blocks_view` (same structural invariant as
1023
+ # daily/weekly/monthly footers); ``total_tokens`` is sourced
1024
+ # from the same view since ``BlocksPanelRow`` doesn't carry
1025
+ # token columns and we don't want to widen that shape.
1026
+ "total_cost_usd": snap.blocks_total_cost_usd,
1027
+ "total_tokens": snap.blocks_total_tokens,
1028
+ }
1029
+
1030
+ # Re-run helper to derive thresholds; mutates rows[*].intensity_bucket
1031
+ # (no-op for builder-constructed rows since values match cost_usd).
1032
+ # Single-source-of-truth with bucket assignment — do NOT re-derive
1033
+ # thresholds with an independent formula.
1034
+ daily_rows = list(snap.daily_panel)
1035
+ daily_thresholds = _compute_intensity_buckets(daily_rows)
1036
+ daily_peak = None
1037
+ if daily_rows:
1038
+ nonzero_rows = [r for r in daily_rows if r.cost_usd > 0]
1039
+ if nonzero_rows:
1040
+ peak_row = max(nonzero_rows, key=lambda r: r.cost_usd)
1041
+ daily_peak = {"date": peak_row.date, "cost_usd": peak_row.cost_usd}
1042
+ daily_env = {
1043
+ "rows": [_daily_row_to_dict(r) for r in daily_rows],
1044
+ "quantile_thresholds": daily_thresholds,
1045
+ "peak": daily_peak,
1046
+ # View-model unification (Bundle 1; spec §6.6): pre-computed
1047
+ # totals via sum-over-visible-rows in the sync thread. Gap days
1048
+ # carry ``cost_usd=0.0`` and ``total_tokens=0`` so summing the
1049
+ # materialized panel rows preserves the gap-free semantics. The
1050
+ # React panel's `rows.reduce(...)` collapses to a single
1051
+ # envelope read with the structural-equality invariant
1052
+ # preserved.
1053
+ "total_cost_usd": snap.daily_total_cost_usd,
1054
+ "total_tokens": snap.daily_total_tokens,
1055
+ }
1056
+
1057
+ # ---- threshold-actions T5: alerts envelope + settings mirror ----
1058
+ # `alerts` is precomputed at sync-thread snapshot-build time (see
1059
+ # `_tui_build_snapshot`'s `_build_alerts_envelope_array(conn)` call)
1060
+ # so this remains a pure render path (no DB I/O on dashboard hot
1061
+ # path). Single source of truth for both the dashboard panel
1062
+ # (slices to 10 client-side) and the modal (renders all 100).
1063
+ #
1064
+ # `alerts_settings` mirrors the validated alerts config block into
1065
+ # the envelope so the dashboard's SettingsOverlay can seed its
1066
+ # fieldset without a separate GET /api/settings round-trip
1067
+ # (parallels how `display.tz` lives at envelope["display"]["tz"]).
1068
+ # Defensive: a corrupt alerts block in `config.json` must not 500
1069
+ # the entire snapshot — fall back to safe defaults and rely on
1070
+ # `_warn_alerts_bad_config_once` for the user-visible signal.
1071
+ alerts_array = list(getattr(snap, "alerts", []) or [])
1072
+ # #268 M4: reuse the precompute's config (or the fallback load_config()
1073
+ # resolved above) — the envelope used to call load_config() a SECOND time
1074
+ # here. Within one tick both reads returned the same file, so this is
1075
+ # byte-identical.
1076
+ _cfg_for_alerts = _raw_config
1077
+ try:
1078
+ _alerts_cfg = _get_alerts_config(_cfg_for_alerts)
1079
+ except _AlertsConfigError as exc:
1080
+ sys.modules["cctally"]._warn_alerts_bad_config_once(exc)
1081
+ _alerts_cfg = {
1082
+ "enabled": False,
1083
+ "weekly_thresholds": [],
1084
+ "five_hour_thresholds": [],
1085
+ "projected_enabled": False,
1086
+ # Mirror the dispatch keys so the new alerts_settings lines
1087
+ # (`notifier` / `command_configured`) don't KeyError on a
1088
+ # corrupt config. Safe defaults: no notifier override, no
1089
+ # configured command.
1090
+ "notifier": "auto",
1091
+ "command_template": None,
1092
+ }
1093
+ # Budget is its OWN config block (issue #19) — source budget fields
1094
+ # from ``_get_budget_config`` (the validated ``budget`` block), NOT
1095
+ # the ``alerts`` block. Defensive: a corrupt budget block must not
1096
+ # 500 the whole snapshot — fall back to "no budget / disabled".
1097
+ try:
1098
+ _budget_cfg = _get_budget_config(_cfg_for_alerts)
1099
+ except _BudgetConfigError:
1100
+ _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
1101
+ "alert_thresholds": [], "projected_enabled": False,
1102
+ "projects": {}, "project_alerts_enabled": False}
1103
+ alerts_settings = {
1104
+ "enabled": _alerts_cfg["enabled"],
1105
+ "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
1106
+ "five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
1107
+ "budget_thresholds": list(_budget_cfg["alert_thresholds"]),
1108
+ "budget_enabled": _budget_alerts_active(_budget_cfg),
1109
+ # Projected-pace opt-in mirrors (#121). Two flags, one per parent
1110
+ # axis — the frontend SettingsOverlay seeds two toggles. Sourced
1111
+ # from the validated getters' ``projected_enabled`` (default False).
1112
+ "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
1113
+ "projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
1114
+ # Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
1115
+ # ``project_budget`` axis dispatch only (the display section always
1116
+ # renders configured projects). Sourced from the validated budget
1117
+ # getter's ``project_alerts_enabled`` (default False) — the frontend
1118
+ # SettingsOverlay seeds a single on/off toggle from it.
1119
+ "project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
1120
+ # Codex budget toggle mirrors (#134). The frontend SettingsOverlay
1121
+ # seeds two toggles (alerts + projected) + a disabled-with-hint empty
1122
+ # state from these three flags. ``_budget_cfg["codex"]`` is ``None`` by
1123
+ # default (no Codex budget) → all three default false/absent safely;
1124
+ # the ``_BudgetConfigError`` fallback dict above lacks a ``codex`` key,
1125
+ # so ``.get("codex")`` → ``None`` is likewise safe.
1126
+ "codex_budget_configured": _budget_cfg.get("codex") is not None,
1127
+ "codex_budget_alerts_enabled": bool((_budget_cfg.get("codex") or {}).get("alerts_enabled")),
1128
+ "codex_projected_enabled": bool((_budget_cfg.get("codex") or {}).get("projected_enabled")),
1129
+ # Alert-dispatch notifier mirror (Phase B). `notifier` is the
1130
+ # validated backend selector ("auto"/"command"/etc.). The raw
1131
+ # `command_template` is NEVER mirrored — it routinely holds secrets
1132
+ # (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
1133
+ # every connected client. We expose only a boolean: is a custom
1134
+ # command configured? (the CLI/config remains the sole writer of the
1135
+ # template itself).
1136
+ "notifier": _alerts_cfg.get("notifier", "auto"),
1137
+ "command_configured": _alerts_cfg.get("command_template") is not None,
1138
+ }
1139
+ # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
1140
+ # Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
1141
+ # FS hit on the hot path). Default true — absence is treated as ON (opt-out,
1142
+ # not opt-in); a hand-edited non-bool surfaces the default. The on/off toggle
1143
+ # is honored entirely at client render time; the kernel + API always emit the
1144
+ # marker data.
1145
+ _dash_cfg = _cfg_for_alerts.get("dashboard") if isinstance(
1146
+ _cfg_for_alerts.get("dashboard"), dict) else {}
1147
+ _cfm = _dash_cfg.get("cache_failure_markers", True)
1148
+ _lt = _dash_cfg.get("live_tail", True)
1149
+ dashboard_prefs = {
1150
+ "cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
1151
+ "live_tail": _lt if isinstance(_lt, bool) else True,
1152
+ }
1153
+
1154
+ # Mirror update-state.json + update-suppress.json into the envelope
1155
+ # so the dashboard's amber "Update available" badge repaints from
1156
+ # the SSE channel rather than requiring a /api/update/status fetch
1157
+ # per check. Without this mirror, a long-open dashboard tab never
1158
+ # learns that the dashboard's `_DashboardUpdateCheckThread` wrote
1159
+ # a fresher latest_version (24h-TTL by default) — the badge stayed
1160
+ # hidden until manual reload. Same precedent as `alerts_settings`:
1161
+ # cheap atomic-rename reads on the snapshot hot path. Failures
1162
+ # produce a `null` block so a missing/corrupt state file doesn't
1163
+ # 500 the entire envelope; the client falls back to the defensive
1164
+ # null-state shape `coerceUpdateState` already understands.
1165
+ #
1166
+ # #268 M4: read from the sync-thread precompute (spec §6) when present so
1167
+ # this stays pure — no update-state file reads per SSE client. The inline
1168
+ # try/except is the fallback for fixtures / the initial snapshot, and keeps
1169
+ # the SAME error sentinels so the derived block is byte-identical.
1170
+ if _precomp is not None:
1171
+ _update_state_envelope = _precomp["update_state"]
1172
+ _update_suppress_envelope = _precomp["update_suppress"]
1173
+ else:
1174
+ try:
1175
+ _update_state_envelope = sys.modules["cctally"]._load_update_state()
1176
+ except sys.modules["cctally"].UpdateError:
1177
+ # _load_update_state() raises on truly malformed JSON. Surface
1178
+ # an _error sentinel so the client renders "no update info" the
1179
+ # same way it does for unreachable /api/update/status.
1180
+ _update_state_envelope = {"_error": "update-state.json invalid"}
1181
+ except Exception:
1182
+ _update_state_envelope = {"_error": "update-state.json read failed"}
1183
+ try:
1184
+ _update_suppress_envelope = sys.modules["cctally"]._load_update_suppress()
1185
+ except Exception:
1186
+ _update_suppress_envelope = {
1187
+ "skipped_versions": [], "remind_after": None,
1188
+ }
1189
+ update_envelope = {
1190
+ "state": _update_state_envelope,
1191
+ "suppress": _update_suppress_envelope,
1192
+ }
1193
+
1194
+ # Doctor aggregate block (spec §5.5). Only the small severity tree
1195
+ # rides on every SSE tick (~120 bytes); the full per-check payload
1196
+ # is fetched lazily via `GET /api/doctor`. `runtime_bind` is the
1197
+ # actual host the dashboard process is bound to (Codex H4) so
1198
+ # `safety.dashboard_bind` reflects the running state, not just
1199
+ # `config.json`. Defensive: never crash the snapshot pipeline on
1200
+ # a doctor failure — surface a synthetic FAIL block with `_error`.
1201
+ #
1202
+ # #268 M4: read the precomputed doctor block from the snapshot (spec §6)
1203
+ # so this render path never forks the `security` keychain subprocess. The
1204
+ # sync thread computes it ONCE per rebuild via `doctor_payload_memo`
1205
+ # (`_tui_precompute_doctor_payload`). The inline try/except below is the
1206
+ # fallback for fixtures / the initial snapshot / positionally-constructed
1207
+ # DataSnapshots (`doctor_payload=None`) and produces the SAME block, so
1208
+ # moving the computation is byte-identical. The lazy `GET /api/doctor`
1209
+ # endpoint keeps computing LIVE (an explicit user refresh must be fresh).
1210
+ _doctor_payload = getattr(snap, "doctor_payload", None)
1211
+ if _doctor_payload is not None:
1212
+ doctor_envelope: "dict" = _doctor_payload
1213
+ else:
1214
+ try:
1215
+ _ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
1216
+ _doc_state = sys.modules["cctally"].doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
1217
+ _doc_report = _ld.run_checks(_doc_state)
1218
+ doctor_envelope = {
1219
+ "severity": _doc_report.overall_severity,
1220
+ "counts": dict(_doc_report.counts),
1221
+ "generated_at": _ld._iso_z(_doc_report.generated_at),
1222
+ "fingerprint": _ld.fingerprint(_doc_report),
1223
+ }
1224
+ except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
1225
+ doctor_envelope = {
1226
+ "severity": "fail",
1227
+ "counts": {"ok": 0, "warn": 0, "fail": 1},
1228
+ "generated_at": _iso_z(now_utc),
1229
+ "fingerprint": "sha1:" + ("0" * 40),
1230
+ "_error": f"{type(exc).__name__}: {exc}",
1231
+ }
1232
+
1233
+ # B1 (#207): the "vs last week" header delta reuses the is_current trend
1234
+ # row's delta_dpp ($/1% vs the previous trend row — normally the prior
1235
+ # subscription week). Select by the is_current FLAG, not snap.trend[-1]:
1236
+ # snap.trend is oldest-first by week_start_date and reset/credit handling
1237
+ # can synthesize an intervening row, so position -1 is not guaranteed to
1238
+ # be the current week (Codex P1). reversed() picks the latest if ever
1239
+ # multiple are flagged. Null-safe: None when no row is current, or when
1240
+ # the current row's delta_dpp is itself None — both hide the stat.
1241
+ _current_trend = next(
1242
+ (r for r in reversed(snap.trend) if r.is_current), None
1243
+ ) if snap.trend else None
1244
+
1245
+ return {
1246
+ "envelope_version": 2,
1247
+ # #278 Theme A: single additive first-paint hydration latch. True only
1248
+ # on the cheap seed + A2's partial republishes (data still being
1249
+ # assembled); False on every complete/stable snapshot. ``getattr``
1250
+ # default keeps positionally-constructed fixture snapshots serializing.
1251
+ "hydrating": bool(getattr(snap, "hydrating", False)),
1252
+ "generated_at": _iso_z(snap.generated_at),
1253
+ # last_sync_at in DataSnapshot is a monotonic float, not a wall
1254
+ # clock — the envelope's wall-clock moment is unknowable from
1255
+ # here, so expose it as None and rely on sync_age_s for the UI.
1256
+ "last_sync_at": None,
1257
+ "sync_age_s": sync_age_s,
1258
+ "last_sync_error": snap.last_sync_error,
1259
+
1260
+ # F1 (server-resolves "local" → IANA): the browser never has to
1261
+ # guess. {tz, resolved_tz, offset_label, offset_seconds} computed
1262
+ # at the snapshot's `generated_at`. Always present, even when
1263
+ # cw / fc are None.
1264
+ "display": display_block,
1265
+
1266
+ "header": {
1267
+ "week_label": week_lbl,
1268
+ "used_pct": used_pct,
1269
+ "five_hour_pct": five_hr,
1270
+ "dollar_per_pct": dollar_pp,
1271
+ "forecast_pct": header_fcast_pct,
1272
+ "forecast_verdict": verdict,
1273
+ "vs_last_week_delta": (
1274
+ _current_trend.delta_dpp if _current_trend is not None else None
1275
+ ),
1276
+ },
1277
+
1278
+ "current_week":
1279
+ None if cw is None else {
1280
+ "used_pct": cw.used_pct,
1281
+ "five_hour_pct": cw.five_hour_pct,
1282
+ "five_hour_resets_in_sec":
1283
+ None if cw.five_hour_resets_at is None
1284
+ else max(0, int((cw.five_hour_resets_at - now_utc).total_seconds())),
1285
+ "spent_usd": cw.spent_usd,
1286
+ "dollar_per_pct": cw.dollars_per_percent,
1287
+ "reset_at_utc": _iso_z(reset_at_utc),
1288
+ "reset_in_sec":
1289
+ None if reset_at_utc is None
1290
+ else max(0, int((reset_at_utc - now_utc).total_seconds())),
1291
+ "last_snapshot_age_sec":
1292
+ None if cw.latest_snapshot_at is None
1293
+ else max(0, int((now_utc - cw.latest_snapshot_at).total_seconds())),
1294
+ "freshness": cw_freshness,
1295
+ # Current 5h block (spec §4.1) — populated upstream by
1296
+ # `_tui_build_current_week`. `getattr` with default keeps
1297
+ # legacy fixture modules that construct TuiCurrentWeek
1298
+ # directly (without the new field) compatible.
1299
+ "five_hour_block": getattr(cw, "five_hour_block", None),
1300
+ "milestones": [
1301
+ {
1302
+ "percent": m.percent,
1303
+ "crossed_at_utc": _iso_z(m.crossed_at),
1304
+ "cumulative_usd": round(m.cumulative_cost_usd, 4),
1305
+ "marginal_usd":
1306
+ None if m.marginal_cost_usd is None
1307
+ else round(m.marginal_cost_usd, 4),
1308
+ "five_hour_pct_at_cross": m.five_hour_pct_at_crossing,
1309
+ }
1310
+ for m in (snap.percent_milestones or [])
1311
+ ],
1312
+ # Spec §5.3 (Codex r1 finding 3) — NEW envelope key
1313
+ # parallel to ``milestones`` (which carries the WEEKLY
1314
+ # timeline). 5h-block milestones for the active block,
1315
+ # in capture-time order, both pre- and post-credit
1316
+ # segments included (bucket B per §3.2 — no
1317
+ # ``reset_event_id`` filter; the React layer renders
1318
+ # repeated thresholds as distinct rows keyed on
1319
+ # ``reset_event_id``). Empty list when no 5h block is
1320
+ # bound or the data source crashed during sync
1321
+ # (recorded on ``last_sync_error``).
1322
+ "five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
1323
+ },
1324
+
1325
+ "forecast":
1326
+ None if fc is None else {
1327
+ "verdict": verdict,
1328
+ "week_avg_projection_pct": fcast_pct,
1329
+ "recent_24h_projection_pct": recent_24h_pct,
1330
+ "budget_100_per_day_usd": budget_100,
1331
+ "budget_90_per_day_usd": budget_90,
1332
+ "confidence": confidence or "unknown",
1333
+ # Map the binary ForecastOutput.confidence ("high" / "low") to
1334
+ # a 7-dot fill scale for the dashboard footer. ForecastOutput
1335
+ # has no numeric grade, so we pick a visually meaningful floor
1336
+ # for "low" (≈30% filled) and a full bar for "high".
1337
+ "confidence_score": (7 if confidence == "high"
1338
+ else 2 if confidence == "low"
1339
+ else 0),
1340
+ "explain": sys.modules["cctally"]._build_forecast_json_payload(fc),
1341
+ },
1342
+
1343
+ "trend":
1344
+ None if not snap.trend else {
1345
+ "weeks": [
1346
+ {
1347
+ "label": w.week_label,
1348
+ "used_pct": w.used_pct,
1349
+ "dollar_per_pct": w.dollars_per_percent,
1350
+ "delta": w.delta_dpp,
1351
+ "is_current": bool(w.is_current),
1352
+ # #264 S3: additive weekly cost (already on the row via
1353
+ # build_trend_view); rendered by the Trend modal's Cost
1354
+ # column. ``getattr`` (like ``project_key`` /
1355
+ # ``trend_history_median_dpp``) tolerates minimal trend
1356
+ # shapes — SimpleNamespace test rows / older fixtures —
1357
+ # that predate this nullable field. ``None`` when the
1358
+ # week has no cost snapshot.
1359
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
1360
+ }
1361
+ for w in snap.trend
1362
+ ],
1363
+ "spark_heights": [w.spark_height for w in snap.trend],
1364
+ "history": [
1365
+ {
1366
+ "label": w.week_label,
1367
+ "used_pct": w.used_pct,
1368
+ "dollar_per_pct": w.dollars_per_percent,
1369
+ "delta": w.delta_dpp,
1370
+ "is_current": bool(w.is_current),
1371
+ # #264 S3: additive weekly cost (see weeks[] above; same
1372
+ # getattr tolerance for minimal/older trend shapes).
1373
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
1374
+ }
1375
+ for w in (snap.weekly_history or [])
1376
+ ],
1377
+ # View-model unification (Bundle 1; spec §6.6): the
1378
+ # pre-computed 3-sample $/% mean. TrendPanel reads this
1379
+ # instead of re-deriving the panel-average.
1380
+ "avg_dollars_per_pct": snap.trend_avg_dollars_per_pct,
1381
+ # Issue #59 follow-up: pre-computed 4-week-median of
1382
+ # non-current ``dollars_per_percent`` over the 12-row
1383
+ # history. TrendModal.tsx's ``median4NonCurrent`` helper
1384
+ # used to compute this client-side; pre-computing on
1385
+ # ``build_trend_view`` keeps the rule
1386
+ # (``sort(last 4 non-current dpps)``, midpoint
1387
+ # ``(s[1]+s[2])/2``) in one place. ``None`` when fewer
1388
+ # than 4 valid non-current samples — modal's client-side
1389
+ # fallback handles the ``null`` case.
1390
+ "history_median_dpp":
1391
+ getattr(snap, "trend_history_median_dpp", None),
1392
+ },
1393
+
1394
+ "weekly": weekly_env,
1395
+ "monthly": monthly_env,
1396
+ "blocks": blocks_env,
1397
+ "daily": daily_env,
1398
+
1399
+ "sessions": {
1400
+ "total": len(snap.sessions),
1401
+ "sort_key": "started_desc",
1402
+ "rows": [
1403
+ {
1404
+ "session_id": s.session_id,
1405
+ "started_utc": _iso_z(s.started_at),
1406
+ "duration_min": int(round(s.duration_minutes)) if s.duration_minutes else 0,
1407
+ "model": s.model_primary,
1408
+ "project": s.project_label,
1409
+ # Projects-panel cross-nav identity (spec §4.1).
1410
+ # Disambiguated display_key matching the projects
1411
+ # envelope's `current_week.rows[].key`; ``None`` when
1412
+ # the projects envelope sub-build failed, the row's
1413
+ # project_path is missing, or the lookup didn't
1414
+ # resolve (the React cell falls back to plain text).
1415
+ "project_key": getattr(s, "project_key", None),
1416
+ "cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
1417
+ # #264 S3: cache-hit % is a plain metric — always serialized
1418
+ # (never gated). ``None`` when the row has no denominator.
1419
+ "cache_hit_pct": round(s.cache_hit_pct, 1) if s.cache_hit_pct is not None else None,
1420
+ # The session title is transcript-derived content: emit the
1421
+ # `title` KEY only when the per-request transcript gate is
1422
+ # open AND a title exists. Omitted (not null) otherwise, so
1423
+ # a gated-off or untitled session renders the client's
1424
+ # em-dash fallback and committed goldens (empty conversation
1425
+ # fixtures -> no title) never carry a `title` key. Default
1426
+ # transcripts_visible=False therefore fails fully closed.
1427
+ **({"title": getattr(s, "title", None)}
1428
+ if (transcripts_visible
1429
+ and getattr(s, "title", None) is not None)
1430
+ else {}),
1431
+ }
1432
+ for s in snap.sessions
1433
+ ],
1434
+ },
1435
+
1436
+ # Projects panel + modal envelope block (spec §5.2).
1437
+ # Populated on the sync thread; the serializer reads it back
1438
+ # unchanged so it stays a pure renderer (no DB I/O). ``None``
1439
+ # on first tick before sync completes; the client renders the
1440
+ # panel-empty state in that case.
1441
+ "projects": getattr(snap, "projects_envelope", None),
1442
+
1443
+ # Cache-report panel + modal envelope block (spec
1444
+ # 2026-05-21-cache-report-panel-design.md §4.2). Snake_case
1445
+ # keys throughout — the envelope is intentionally snake_case
1446
+ # end-to-end (envelope.ts:189). ``None`` on first tick before
1447
+ # sync completes; the client renders the panel-empty state in
1448
+ # that case. envelope_version stays at 2 (additive optional
1449
+ # field, matches the update? / doctor? precedent).
1450
+ "cache_report": _cache_report_snapshot_to_dict(
1451
+ getattr(snap, "cache_report", None)
1452
+ ),
1453
+
1454
+ # threshold-actions T5: see prelude above for rationale.
1455
+ "alerts": alerts_array,
1456
+ "alerts_settings": alerts_settings,
1457
+
1458
+ # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
1459
+ # §5). Additive optional, like alerts_settings — envelope_version
1460
+ # stays at 2. The client derives `markersEnabled` from this, defaulting
1461
+ # to true when the field is undefined (older server / first tick).
1462
+ "dashboard_prefs": dashboard_prefs,
1463
+
1464
+ # update-subcommand SSE mirror (see comment above the
1465
+ # `_load_update_state()` block). Shape matches GET
1466
+ # /api/update/status's payload (`{state, suppress}`) so the
1467
+ # dashboard client's existing coerceUpdateState/Suppress logic
1468
+ # consumes both surfaces uniformly.
1469
+ "update": update_envelope,
1470
+
1471
+ # Doctor aggregate-only block (spec §5.5). Full per-check
1472
+ # report fetched lazily via GET /api/doctor.
1473
+ "doctor": doctor_envelope,
1474
+
1475
+ # Preview channel (CCTALLY_CHANNEL=preview): additive-optional
1476
+ # `channel` key. Omitted when the marker is unset so prod payloads
1477
+ # (and the dashboard goldens) stay byte-identical; feeds the header
1478
+ # PREVIEW badge when set.
1479
+ **sys.modules["_cctally_dashboard"]._channel_env_fragment(),
1480
+ }
1481
+
1482
+
1483
+ def _session_detail_to_envelope(detail: "TuiSessionDetail") -> dict:
1484
+ """Serialize TuiSessionDetail for GET /api/session/:id (spec §3.2, §4.6.4).
1485
+
1486
+ Field names stay parallel to the live ``sessions.rows[]`` envelope
1487
+ (``session_id``, ``started_utc``, ``cost_usd``) so the JS client can
1488
+ treat this as an enrichment of a session row. ``models`` is flattened
1489
+ from ``list[tuple[str, str]]`` to ``list[{name, role}]`` for JSON
1490
+ palatability. ``cost_per_model`` is similarly flattened.
1491
+
1492
+ Pure function — no I/O, no clocks. Dataclass fields mirror exactly
1493
+ (``started_at`` → ``started_utc``, ``last_activity_at`` →
1494
+ ``last_activity_utc``, ``duration_minutes`` → ``duration_min`` as an
1495
+ int for parity with sessions.rows[]). There is no per-entry array —
1496
+ the underlying aggregator is session-level (spec §4.6.4).
1497
+ """
1498
+ return {
1499
+ "session_id": detail.session_id,
1500
+ "started_utc": _iso_z(detail.started_at),
1501
+ "last_activity_utc": _iso_z(detail.last_activity_at),
1502
+ "duration_min": int(round(detail.duration_minutes)) if detail.duration_minutes else 0,
1503
+ "project_label": detail.project_label,
1504
+ "project_path": detail.project_path,
1505
+ "source_paths": list(detail.source_paths),
1506
+ "models": [
1507
+ {"name": name, "role": role}
1508
+ for (name, role) in detail.models
1509
+ ],
1510
+ "input_tokens": detail.input_tokens,
1511
+ "cache_creation_tokens": detail.cache_creation_tokens,
1512
+ "cache_read_tokens": detail.cache_read_tokens,
1513
+ "output_tokens": detail.output_tokens,
1514
+ "cache_hit_pct": detail.cache_hit_pct,
1515
+ "cost_per_model": [
1516
+ {"model": name, "cost_usd": round(cost, 4)}
1517
+ for (name, cost) in detail.cost_per_model
1518
+ ],
1519
+ "cost_total_usd": round(detail.cost_total_usd, 4),
1520
+ }